@msafe/sui3-sdk 0.0.2-pre-9f39791.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.
Files changed (60) hide show
  1. package/.eslintignore +3 -0
  2. package/.eslintrc +87 -0
  3. package/.idea/inspectionProfiles/Project_Default.xml +6 -0
  4. package/.idea/jsLinters/eslint.xml +6 -0
  5. package/.idea/misc.xml +6 -0
  6. package/.idea/modules.xml +8 -0
  7. package/.idea/msafe-sui3-sdk.iml +9 -0
  8. package/.idea/vcs.xml +6 -0
  9. package/.prettierrc +22 -0
  10. package/README.md +3 -0
  11. package/jest.config.ts +63 -0
  12. package/package.json +54 -0
  13. package/scripts/prerelease.sh +5 -0
  14. package/src/backend/CoreDatabase.ts +55 -0
  15. package/src/backend/PseudoBackend.ts +851 -0
  16. package/src/backend/interface.ts +57 -0
  17. package/src/backend/types.ts +22 -0
  18. package/src/core/CreateHelper.ts +76 -0
  19. package/src/core/MSafeAccount.ts +167 -0
  20. package/src/core/MSafeClient.ts +91 -0
  21. package/src/core/MessageHelper.ts +63 -0
  22. package/src/core/PublicKeyHelper.ts +88 -0
  23. package/src/core/index.ts +4 -0
  24. package/src/globals/MSafeGlobals.ts +48 -0
  25. package/src/globals/const.ts +95 -0
  26. package/src/globals/index.ts +2 -0
  27. package/src/index.ts +5 -0
  28. package/src/transactions/coin-transfer.ts +64 -0
  29. package/src/transactions/index.ts +1 -0
  30. package/src/transactions/intention.ts +63 -0
  31. package/src/transactions/object-transfer.ts +66 -0
  32. package/src/transactions/reject.ts +17 -0
  33. package/src/transactions/stream.ts +1 -0
  34. package/src/types/creation.ts +19 -0
  35. package/src/types/index.ts +3 -0
  36. package/src/types/msafe.ts +79 -0
  37. package/src/types/wallet.ts +23 -0
  38. package/src/utils/buffer.ts +11 -0
  39. package/src/utils/coin.ts +64 -0
  40. package/src/utils/crypto.ts +95 -0
  41. package/src/utils/format.ts +25 -0
  42. package/src/utils/index.ts +6 -0
  43. package/src/utils/multi-sig.ts +113 -0
  44. package/src/utils/sui.ts +90 -0
  45. package/temp_package.json +54 -0
  46. package/test/lib/TestHelper.ts +94 -0
  47. package/test/lib/account.ts +87 -0
  48. package/test/lib/config.ts +9 -0
  49. package/test/lib/faucet.ts +49 -0
  50. package/test/unit/backend/backend.test.ts +367 -0
  51. package/test/unit/core/CreateHelper.test.ts +121 -0
  52. package/test/unit/core/MessageHelper.test.ts +32 -0
  53. package/test/unit/core/PublicKeyHelper.test.ts +80 -0
  54. package/test/unit/core/msafe.test.ts +298 -0
  55. package/test/unit/utils/buffer.test.ts +17 -0
  56. package/test/unit/utils/crypto.test.ts +32 -0
  57. package/test/unit/utils/multi-sig.test.ts +47 -0
  58. package/test/unit/utils/sui.test.ts +25 -0
  59. package/tsconfig.json +37 -0
  60. package/tsup.config.ts +9 -0
@@ -0,0 +1,851 @@
1
+ import 'reflect-metadata';
2
+ import {
3
+ User,
4
+ UserMSafe,
5
+ HistoryTransaction,
6
+ MSafe,
7
+ TransactionIntention,
8
+ UserVote,
9
+ PendingTransaction,
10
+ } from '@msafe/sui3-model/core';
11
+ import { SuiClient } from '@mysten/sui.js/client';
12
+ import { PublicKey, SerializedSignature } from '@mysten/sui.js/cryptography';
13
+ import { SignatureScheme } from '@mysten/sui.js/src/cryptography/signature-scheme';
14
+ import { MoreThanOrEqual } from 'typeorm';
15
+
16
+ import { CoreDB } from '@/backend/CoreDatabase';
17
+ import { IBackend } from '@/backend/interface';
18
+ import { CreateMSafeParams, JWTToken, UserWithOwnedMSafe } from '@/backend/types';
19
+ import { MessageHelper } from '@/core/MessageHelper';
20
+ import { DBConfig } from '@/globals/const';
21
+ import { IntentionHelper, TxIntention } from '@/transactions/intention';
22
+ import { FutureIntention, HistorySendTx, MSafeAccountInfo, PendingTx } from '@/types/msafe';
23
+ import { Uint8ArrayToHex, HexToUint8Array } from '@/utils/buffer';
24
+ import { PublicKeySerde, SignatureVerifier } from '@/utils/crypto';
25
+ import { Formatter } from '@/utils/format';
26
+ import { RawMultiSig } from '@/utils/multi-sig';
27
+
28
+ /**
29
+ * Represents a PseudoBackend class that implements the IBackend interface.
30
+ * This class is responsible for interacting with a database and performing various operations.
31
+ * Notice: For backend production code, all DB errors need to be handled correctly
32
+ * TODO: Use QueryRunner to make all db operations atomic.
33
+ * TODO: Permission validation for each request.
34
+ * TODO: More input validation and normalization.
35
+ */
36
+ export class PseudoBackend implements IBackend {
37
+ private _token: JWTToken;
38
+
39
+ private constructor(
40
+ public readonly db: CoreDB,
41
+ private readonly _suiClient: SuiClient,
42
+ ) {}
43
+
44
+ static async New(dbConfig: DBConfig, suiClient: SuiClient) {
45
+ const db = await CoreDB.New(dbConfig);
46
+ return new PseudoBackend(db, suiClient);
47
+ }
48
+
49
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars,unused-imports/no-unused-vars
50
+ async isJWTTokenValid(_jwt: JWTToken): Promise<boolean> {
51
+ return true;
52
+ }
53
+
54
+ async authSign(input: {
55
+ address: string;
56
+ message: string;
57
+ signature: SerializedSignature;
58
+ walletType: string;
59
+ }): Promise<JWTToken> {
60
+ const timestamp = MessageHelper.deWelcomeMessage(input.message);
61
+ if (!timestamp) {
62
+ throw new Error('Invalid welcome message');
63
+ }
64
+ // Check if the signing message has expired
65
+ const date = Date.parse(timestamp);
66
+ if (new Date().getTime() - date < 0) {
67
+ throw new Error('Invalid timestamp');
68
+ }
69
+ if (new Date().getTime() - date > 10 * 1000) {
70
+ throw new Error('Signing message expired');
71
+ }
72
+
73
+ const publicKey = await SignatureVerifier.getPublicKeyFromPersonalSignature({
74
+ messageStr: input.message,
75
+ signature: input.signature,
76
+ });
77
+ if (!Formatter.isSuiAddressEqual(input.address, publicKey.toSuiAddress())) {
78
+ throw new Error('Invalid signature');
79
+ }
80
+ // Add user data to database
81
+ await this.db.upsertUser({
82
+ address: input.address,
83
+ publicKey,
84
+ lastLogin: new Date(),
85
+ });
86
+
87
+ // Add wallet type
88
+ await this.db.updateUserWalletType({
89
+ address: input.address,
90
+ walletType: input.walletType,
91
+ });
92
+
93
+ this._token = '';
94
+ return this._token;
95
+ }
96
+
97
+ setJWTToken(token: JWTToken) {
98
+ this._token = token;
99
+ }
100
+
101
+ async getPublicKey(address: string) {
102
+ const user = await this.model.user.findOneBy({
103
+ address,
104
+ });
105
+ if (user === null) {
106
+ return undefined;
107
+ }
108
+ return PublicKeySerde.de({
109
+ publicKey: user.publicKey,
110
+ scheme: user.schema as SignatureScheme,
111
+ });
112
+ }
113
+
114
+ async getPublicKeyBatch(addresses: string[]): Promise<(PublicKey | undefined)[]> {
115
+ const res: (PublicKey | undefined)[] = [];
116
+ for (let i = 0; i < addresses.length; i++) {
117
+ const address = addresses[i];
118
+ const user = await this.getUser(address);
119
+ res.push(
120
+ user
121
+ ? PublicKeySerde.de({
122
+ publicKey: user.publicKey,
123
+ scheme: user.schema as SignatureScheme,
124
+ })
125
+ : undefined,
126
+ );
127
+ }
128
+ return res;
129
+ }
130
+
131
+ async createMSafeAccount(input: CreateMSafeParams) {
132
+ const ms = new RawMultiSig(input);
133
+ const msafeAddr = ms.suiAddress;
134
+
135
+ const signingMsg = MessageHelper.createMSafeMessage(msafeAddr);
136
+ const targetAddr = input.ownerWithWeight[0].address;
137
+ const verifyResult = await SignatureVerifier.verifyPersonalSignature({
138
+ messageStr: signingMsg,
139
+ signature: input.signature,
140
+ targetAddress: targetAddr,
141
+ });
142
+ if (!verifyResult) {
143
+ throw new Error('Signature verification failed');
144
+ }
145
+ // TODO: In production, we will also need to verify whether the
146
+ // JWT token align with the creator (targetAddr)
147
+ // Check and verify the creation message.
148
+ if (input.name.length > 128) {
149
+ throw new Error('Name too long');
150
+ }
151
+ if (input.description && input.description.length > 512) {
152
+ throw new Error('Description too long');
153
+ }
154
+ const creatorAddress = input.ownerWithWeight[0].address;
155
+ const creator = await this.getUser(creatorAddress);
156
+ if (creator === null) {
157
+ throw new Error('Creator not found');
158
+ }
159
+ if (creator.nonce !== input.creationNonce) {
160
+ throw new Error('Nonce not match');
161
+ }
162
+ const msafeExistCheck = await this.model.msafe.findOneBy({
163
+ address: msafeAddr,
164
+ });
165
+ if (msafeExistCheck !== null) {
166
+ throw new Error('MSafe already exist in database');
167
+ }
168
+ input.ownerWithWeight.forEach((ownerInfo) => {
169
+ if (!Formatter.isSuiAddressEqual(ownerInfo.address, ownerInfo.publicKey.toSuiAddress())) {
170
+ throw new Error('Sui address public key not match');
171
+ }
172
+ });
173
+
174
+ // For each manager, add user info if not exist. Add userMSafe entry.
175
+ for (let i = 0; i !== input.ownerWithWeight.length; i++) {
176
+ const ownerInfo = input.ownerWithWeight[i];
177
+ // Creator has been verified and already exist in database
178
+ if (i !== 0) {
179
+ const coManager = await this.getUser(ownerInfo.address);
180
+ const serPK = PublicKeySerde.ser(ownerInfo.publicKey);
181
+ // Add user with public key if not exist
182
+ if (coManager === null) {
183
+ const user: User = {
184
+ address: ownerInfo.address,
185
+ publicKey: serPK.publicKey,
186
+ schema: serPK.scheme,
187
+ nonce: 0,
188
+ lastLogin: new Date(),
189
+ };
190
+ await this.model.user.save(user);
191
+ }
192
+ }
193
+
194
+ // Add userMSafe record
195
+ const userMSafe: UserMSafe = {
196
+ userAddress: ownerInfo.address,
197
+ msafeAddress: msafeAddr,
198
+ index: i,
199
+ weight: ownerInfo.weight,
200
+ status: i === 0 ? 'active' : 'pending',
201
+ };
202
+ await this.model.userMSafe.save(userMSafe);
203
+ }
204
+
205
+ // Increment the creator nonce
206
+ const creationNonce = creator.nonce;
207
+ creator.nonce++;
208
+ await this.model.user.save(creator);
209
+
210
+ // Add MSafe record
211
+ const msafe: MSafe = {
212
+ address: msafeAddr,
213
+ creationNonce,
214
+ creator: creatorAddress,
215
+ name: input.name,
216
+ description: input.description,
217
+ threshold: input.threshold,
218
+ };
219
+ await this.model.msafe.save(msafe);
220
+ }
221
+
222
+ async getMSafeAccountInfo(msafeAddress: string): Promise<MSafeAccountInfo> {
223
+ // TODO: In production, we need verify whether the msafe
224
+ // account belongs to the single signer from JWT token.
225
+
226
+ const msafe = await this.model.msafe.findOneBy({ address: msafeAddress });
227
+ if (msafe === null) {
228
+ throw new Error('MSafe not found');
229
+ }
230
+ const userMSafes = await this.model.userMSafe.find({
231
+ where: {
232
+ msafeAddress,
233
+ },
234
+ order: {
235
+ index: 'asc',
236
+ },
237
+ });
238
+ if (userMSafes.length === 0) {
239
+ throw new Error('MSafe does not have user info.');
240
+ }
241
+ const users = await Promise.all(userMSafes.map((um) => this.getUser(um.userAddress)));
242
+ users.forEach((user) => {
243
+ if (user === null) {
244
+ throw new Error('User not found');
245
+ }
246
+ });
247
+ const ownersWithWeightPK = userMSafes.map((userMSafe, i) => ({
248
+ address: userMSafe.userAddress,
249
+ weight: userMSafe.weight,
250
+ publicKey: PublicKeySerde.de({ publicKey: users[i]!.publicKey, scheme: users[i]!.schema as SignatureScheme }),
251
+ }));
252
+ return {
253
+ address: msafeAddress,
254
+ ownersWithWeightPK,
255
+ threshold: msafe.threshold,
256
+ name: msafe.name,
257
+ description: msafe.description,
258
+ creationNonce: msafe.creationNonce,
259
+ };
260
+ }
261
+
262
+ async getUserInfo(userAddress: string): Promise<UserWithOwnedMSafe> {
263
+ // TODO: Do permission check on user JWT token.
264
+ const user = await this.getUser(userAddress);
265
+ if (user === null) {
266
+ throw new Error('404: user not found');
267
+ }
268
+ // TODO: Add invitation status as well.
269
+ const msafeAccounts = await this.model.userMSafe.findBy({ userAddress });
270
+ const ownedMSafe = await Promise.all(
271
+ msafeAccounts.map(async (msafeAccount) => this.getMSafeAccountInfo(msafeAccount.msafeAddress)),
272
+ );
273
+ return {
274
+ address: userAddress,
275
+ publicKey: user.publicKey,
276
+ schema: user.schema,
277
+ creationNonce: user.nonce,
278
+ ownedMSafe,
279
+ };
280
+ }
281
+
282
+ async getPendingTransactions(msafeAddress: string): Promise<PendingTx[]> {
283
+ const pendings = await this.db.coreModel.pendingTransaction.findBy({
284
+ msafeAddress,
285
+ });
286
+ if (pendings.length === 0) {
287
+ return [];
288
+ }
289
+ const votes = await Promise.all(
290
+ pendings.map((pending) => this.db.coreModel.userVote.findBy({ txDigest: pending.digest, isValid: true })),
291
+ );
292
+ const intention = await this.model.transactionIntention.findOneBy({
293
+ msafeAddress,
294
+ sequenceNumber: pendings[0].sequenceNumber,
295
+ });
296
+ if (intention === null) {
297
+ throw new Error('Intention not found');
298
+ }
299
+ const intent = IntentionHelper.de(intention.data);
300
+ return pendings.map((pending, i) => ({
301
+ digest: pending.digest,
302
+ payload: pending.payload,
303
+ msafeAddress: pending.msafeAddress,
304
+ isRejectTx: pending.isRejectTx,
305
+ creator: pending.creator,
306
+ createdAt: pending.createdAt as Date,
307
+ votes: votes[i].map((vote) => ({
308
+ userAddress: vote.userAddress,
309
+ signature: vote.signature,
310
+ timestamp: vote.updatedAt as Date,
311
+ })),
312
+ sequenceNumber: pending.sequenceNumber,
313
+ intention: pending.isRejectTx ? undefined : intent,
314
+ }));
315
+ }
316
+
317
+ async getCurrentSequenceNumber(msafeAddress: string): Promise<number> {
318
+ // TODO: validate user permission
319
+ const maxSNHistory = await this.model.historyTransaction.findOne({
320
+ where: { msafeAddress },
321
+ order: { sequenceNumber: 'desc' },
322
+ });
323
+ return maxSNHistory === null ? 0 : maxSNHistory.sequenceNumber + 1;
324
+ }
325
+
326
+ async getNextSequenceNumber(msafeAddress: string): Promise<number> {
327
+ // TODO: validate user permission
328
+ const maxSNIntention = await this.model.transactionIntention.findOne({
329
+ where: { msafeAddress },
330
+ order: { sequenceNumber: 'desc' },
331
+ });
332
+ return maxSNIntention === null ? 0 : maxSNIntention.sequenceNumber + 1;
333
+ }
334
+
335
+ async getHistoryTransactions(msafeAddress: string): Promise<HistorySendTx[]> {
336
+ // TODO: Add pagination options. Filter by sequence number based
337
+ // on pagination options.
338
+ // Notice there can be multiple transactions with the same sequence
339
+ // number.
340
+ // TODO: Add permission validation
341
+ const transactions = await this.db.coreModel.historyTransaction.find({
342
+ where: {
343
+ msafeAddress,
344
+ },
345
+ order: { sequenceNumber: 'desc' },
346
+ });
347
+ const votes = await Promise.all(
348
+ transactions.map((tx) =>
349
+ this.db.coreModel.userVote.findBy({
350
+ txDigest: tx.digest,
351
+ isValid: true,
352
+ }),
353
+ ),
354
+ );
355
+ return transactions.map((tx, i) => ({
356
+ digest: tx.digest,
357
+ payload: tx.payload,
358
+ msafeAddress: tx.msafeAddress,
359
+ isRejectTx: tx.isRejectTx,
360
+ status: tx.status,
361
+ creator: tx.creator,
362
+ createdAt: tx.createdAt as Date,
363
+ sequenceNumber: tx.sequenceNumber,
364
+ votes: votes[i].map((vote) => ({
365
+ userAddress: vote.userAddress,
366
+ timestamp: vote.updatedAt as Date,
367
+ })),
368
+ }));
369
+ }
370
+
371
+ async getFutureIntentions(msafeAddress: string): Promise<FutureIntention[]> {
372
+ // TODO: Add pagination options, add user permission validation
373
+ const currentSequenceNumber = await this.getCurrentSequenceNumber(msafeAddress);
374
+ const hasPending = (await this.model.pendingTransaction.findOneBy({ msafeAddress })) !== null;
375
+ const futureSNStart = hasPending ? currentSequenceNumber + 1 : currentSequenceNumber;
376
+
377
+ const futureTxs = await this.model.transactionIntention.find({
378
+ where: { msafeAddress, sequenceNumber: MoreThanOrEqual(futureSNStart) },
379
+ order: { sequenceNumber: 'asc' },
380
+ });
381
+ return futureTxs.map((tx) => ({
382
+ intention: IntentionHelper.de(tx.data),
383
+ msafeAddress: tx.msafeAddress,
384
+ sequenceNumber: tx.sequenceNumber,
385
+ rawData: tx.data,
386
+ txType: tx.txType,
387
+ txSubType: tx.txSubType,
388
+ status: tx.status,
389
+ statusRemark: tx.statusRemark,
390
+ creator: tx.creator,
391
+ createdAt: tx.createdAt as Date,
392
+ }));
393
+ }
394
+
395
+ async proposeIntention(input: {
396
+ intention: TxIntention;
397
+ sequenceNumber: number;
398
+ userAddress: string;
399
+ msafeAddress: string;
400
+ signature: SerializedSignature;
401
+ }) {
402
+ // Validation:
403
+ // 1. Whether the current wallet have permission
404
+ // 2. Verify signature
405
+ const userMSafe = await this.model.userMSafe.findOneBy({
406
+ msafeAddress: input.msafeAddress,
407
+ userAddress: input.userAddress,
408
+ });
409
+ if (!userMSafe) {
410
+ throw new Error('User does not have permission to propose intention');
411
+ }
412
+ const verified = await SignatureVerifier.verifyPersonalSignature({
413
+ messageStr: MessageHelper.proposeIntentionMessage({ intention: input.intention, sn: input.sequenceNumber }),
414
+ signature: input.signature,
415
+ targetAddress: input.userAddress,
416
+ });
417
+ if (!verified) {
418
+ throw new Error('Invalid signature');
419
+ }
420
+
421
+ const sequenceNumber = await this.model.transactionIntention.count({
422
+ where: {
423
+ msafeAddress: input.msafeAddress,
424
+ },
425
+ });
426
+ if (sequenceNumber !== input.sequenceNumber) {
427
+ throw new Error('Sequence number not expected');
428
+ }
429
+ const intention: TransactionIntention = {
430
+ msafeAddress: input.msafeAddress,
431
+ sequenceNumber,
432
+ ...IntentionHelper.getTxType(input.intention),
433
+ data: IntentionHelper.ser(input.intention),
434
+ status: 'future',
435
+ creator: input.userAddress,
436
+ };
437
+ await this.model.transactionIntention.save(intention);
438
+ }
439
+
440
+ // Propose a pending transaction. Require the msafe account
441
+ // Does not have any pending transactions.
442
+ async proposePendingTransaction(input: {
443
+ intention: TxIntention;
444
+ userAddress: string;
445
+ msafeAddress: string;
446
+ digest: string;
447
+ signature: SerializedSignature;
448
+ }) {
449
+ const userMSafe = await this.model.userMSafe.findOneBy({
450
+ msafeAddress: input.msafeAddress,
451
+ userAddress: input.userAddress,
452
+ });
453
+ if (userMSafe === null) {
454
+ throw new Error('Unauthorized');
455
+ }
456
+
457
+ const msafePendings = await this.model.pendingTransaction.findBy({
458
+ msafeAddress: input.msafeAddress,
459
+ });
460
+ if (msafePendings.length !== 0) {
461
+ throw new Error('Still have pending transaction');
462
+ }
463
+
464
+ const txb = await IntentionHelper.buildTxb({
465
+ suiClient: this._suiClient,
466
+ intention: input.intention,
467
+ sender: input.msafeAddress,
468
+ });
469
+ const payload = await txb.build({ client: this._suiClient });
470
+ const txDigest = await txb.getDigest({ client: this._suiClient });
471
+ if (txDigest !== input.digest) {
472
+ throw new Error('Transaction digest un-match');
473
+ }
474
+
475
+ const verified = await SignatureVerifier.verifyTransactionSignature({
476
+ payload,
477
+ targetAddress: input.userAddress,
478
+ signature: input.signature,
479
+ });
480
+ if (!verified) {
481
+ throw new Error('Failed to verify signature');
482
+ }
483
+
484
+ // Write intention
485
+ const maxSNHistory = await this.model.historyTransaction.findOne({
486
+ where: { msafeAddress: input.msafeAddress },
487
+ order: { sequenceNumber: 'desc' },
488
+ });
489
+ const sequenceNumber = maxSNHistory === null ? 0 : maxSNHistory.sequenceNumber + 1;
490
+ const intention: TransactionIntention = {
491
+ msafeAddress: input.msafeAddress,
492
+ sequenceNumber,
493
+ ...IntentionHelper.getTxType(input.intention),
494
+ data: IntentionHelper.ser(input.intention),
495
+ status: 'future',
496
+ creator: input.userAddress,
497
+ };
498
+ await this.model.transactionIntention.save(intention);
499
+
500
+ // Write pending transaction
501
+ const pendingTx: PendingTransaction = {
502
+ digest: txDigest,
503
+ msafeAddress: input.msafeAddress,
504
+ payload: Uint8ArrayToHex(payload),
505
+ sequenceNumber,
506
+ isRejectTx: false,
507
+ creator: input.userAddress,
508
+ };
509
+ await this.model.pendingTransaction.save(pendingTx);
510
+
511
+ // Write user vote
512
+ const userVote: UserVote = {
513
+ userAddress: input.userAddress,
514
+ txDigest,
515
+ msafeAddress: input.msafeAddress,
516
+ signature: input.signature,
517
+ isValid: true,
518
+ };
519
+ await this.model.userVote.save(userVote);
520
+ }
521
+
522
+ // Calls for the first reject transaction
523
+ async rejectCurrentTx(input: {
524
+ userAddress: string;
525
+ msafeAddress: string;
526
+ digest: string;
527
+ signature: SerializedSignature;
528
+ }) {
529
+ const userMSafe = await this.model.userMSafe.findOneBy({
530
+ userAddress: input.userAddress,
531
+ msafeAddress: input.msafeAddress,
532
+ });
533
+ if (userMSafe === null) {
534
+ throw new Error('User does not have permission to MSafe');
535
+ }
536
+
537
+ const currentPending = await this.model.pendingTransaction.findOneBy({
538
+ msafeAddress: input.msafeAddress,
539
+ isRejectTx: false,
540
+ });
541
+ if (currentPending === null) {
542
+ throw new Error('No active pending transaction');
543
+ }
544
+
545
+ const txb = await IntentionHelper.buildRejectTransaction({
546
+ msafeAddress: input.msafeAddress,
547
+ payloadToReject: currentPending.payload,
548
+ });
549
+ const payload = await txb.build({ client: this._suiClient });
550
+ const digest = await txb.getDigest({ client: this._suiClient });
551
+ if (input.digest !== digest) {
552
+ throw new Error('Digest not match');
553
+ }
554
+ const verified = await SignatureVerifier.verifyTransactionSignature({
555
+ payload,
556
+ targetAddress: input.userAddress,
557
+ signature: input.signature,
558
+ });
559
+ if (!verified) {
560
+ throw new Error('Signature unverified');
561
+ }
562
+
563
+ const existPendingReject = await this.model.pendingTransaction.findOneBy({
564
+ msafeAddress: input.msafeAddress,
565
+ isRejectTx: true,
566
+ });
567
+ if (existPendingReject !== null) {
568
+ await this.voteForTransaction({
569
+ txDigest: existPendingReject.digest,
570
+ msafeAddress: input.msafeAddress,
571
+ userAddress: input.userAddress,
572
+ signature: input.signature,
573
+ });
574
+ return;
575
+ }
576
+
577
+ const rejectPayloadStr = Uint8ArrayToHex(payload);
578
+ const rejectDigest = await txb.getDigest({ client: this._suiClient });
579
+ const rejectPending: PendingTransaction = {
580
+ msafeAddress: input.msafeAddress,
581
+ digest: rejectDigest,
582
+ payload: rejectPayloadStr,
583
+ sequenceNumber: currentPending.sequenceNumber,
584
+ isRejectTx: true,
585
+ creator: input.userAddress,
586
+ };
587
+ await this.model.pendingTransaction.save(rejectPending);
588
+
589
+ // Vote for the transaction
590
+ const rejectVote: UserVote = {
591
+ userAddress: input.userAddress,
592
+ msafeAddress: input.msafeAddress,
593
+ txDigest: rejectDigest,
594
+ signature: input.signature,
595
+ isValid: true,
596
+ };
597
+ await this.model.userVote.save(rejectVote);
598
+
599
+ // If user has previously voted in the other way, revoke the vote
600
+ const existVote = await this.model.userVote.findOneBy({
601
+ txDigest: currentPending.digest,
602
+ userAddress: input.userAddress,
603
+ });
604
+ if (existVote !== null) {
605
+ await this.model.userVote.update(
606
+ { userAddress: input.userAddress, txDigest: currentPending.digest },
607
+ { isValid: false },
608
+ );
609
+ }
610
+ }
611
+
612
+ async voteForTransaction(input: {
613
+ txDigest: string;
614
+ msafeAddress: string;
615
+ userAddress: string;
616
+ signature: SerializedSignature;
617
+ }) {
618
+ const pendingTx = await this.model.pendingTransaction.findOneBy({ digest: input.txDigest });
619
+ if (!pendingTx) {
620
+ throw new Error(`Pending transaction not found: ${input.txDigest}`);
621
+ }
622
+ if (pendingTx.msafeAddress !== input.msafeAddress) {
623
+ throw new Error('MSafe address not match');
624
+ }
625
+
626
+ // Ensure user owns the msafe address
627
+ const userMSafe = await this.model.userMSafe.findOneBy({
628
+ userAddress: input.userAddress,
629
+ msafeAddress: input.msafeAddress,
630
+ });
631
+ if (userMSafe === null) {
632
+ throw new Error(`User (${input.userAddress}) does not have permission on MSafe account (${input.msafeAddress})`);
633
+ }
634
+
635
+ // Verify the signature
636
+ const payload = HexToUint8Array(pendingTx.payload);
637
+ const verified = await SignatureVerifier.verifyTransactionSignature({
638
+ payload,
639
+ targetAddress: input.userAddress,
640
+ signature: input.signature,
641
+ });
642
+ if (!verified) {
643
+ throw new Error('Invalid signature');
644
+ }
645
+
646
+ // If user has other vote on the reject transaction, Add the user vote.
647
+ const rejectPendingTx = await this.model.pendingTransaction.findOneBy({
648
+ msafeAddress: input.msafeAddress,
649
+ sequenceNumber: pendingTx.sequenceNumber,
650
+ isRejectTx: !pendingTx.isRejectTx,
651
+ });
652
+ if (rejectPendingTx !== null) {
653
+ await this.model.userVote.update(
654
+ {
655
+ userAddress: input.userAddress,
656
+ txDigest: rejectPendingTx.digest,
657
+ isValid: true,
658
+ },
659
+ { isValid: false },
660
+ );
661
+ }
662
+ const existVote = await this.model.userVote.exist({
663
+ where: {
664
+ txDigest: input.txDigest,
665
+ userAddress: input.userAddress,
666
+ },
667
+ });
668
+ if (!existVote) {
669
+ const userVote: UserVote = {
670
+ txDigest: input.txDigest,
671
+ userAddress: input.userAddress,
672
+ msafeAddress: input.msafeAddress,
673
+ signature: input.signature,
674
+ isValid: true,
675
+ };
676
+ await this.model.userVote.save(userVote);
677
+ } else {
678
+ await this.model.userVote.update(
679
+ {
680
+ txDigest: input.txDigest,
681
+ userAddress: input.userAddress,
682
+ },
683
+ { isValid: true },
684
+ );
685
+ }
686
+ }
687
+
688
+ // Mock the process of an executed transaction
689
+ // Here only the essential logic of transaction processing logic
690
+ // is implemented. Need more transaction parsing from the fetcher
691
+ // module.
692
+ //
693
+ // TODO: User queryRunner to make the transaction atomic.
694
+ async processExecutedTransaction(digest: string) {
695
+ // TODO: Process the transaction result from blockchain.
696
+ const historyTx = await this.model.historyTransaction.findOneBy({ digest });
697
+ if (historyTx) {
698
+ // Already processed, directly return
699
+ const allHistories = await this.model.historyTransaction.findBy({
700
+ msafeAddress: (historyTx as HistoryTransaction).msafeAddress,
701
+ });
702
+ console.log(allHistories);
703
+ return;
704
+ }
705
+ const pendingTx = await this.model.pendingTransaction.findOneBy({ digest });
706
+ if (!pendingTx) {
707
+ throw new Error('Transaction digest not found');
708
+ }
709
+ // Mark the intention as processed.
710
+ await this.model.transactionIntention.update(
711
+ { msafeAddress: pendingTx.msafeAddress, sequenceNumber: pendingTx.sequenceNumber },
712
+ { status: 'processed' },
713
+ );
714
+ // Delete the current pending transaction.
715
+ // There can be multiple transactions. mark one as executed, other as rejected
716
+ const pendings = await this.model.pendingTransaction.findBy({
717
+ msafeAddress: pendingTx.msafeAddress,
718
+ sequenceNumber: pendingTx.sequenceNumber,
719
+ });
720
+ if (pendings.length === 0) {
721
+ throw new Error('Pending transaction not found');
722
+ }
723
+ // Add to transaction history and delete the pending transaction.
724
+ const executionResult: 'success' | 'failed' = 'success';
725
+ for (let i = 0; i !== pendings.length; i++) {
726
+ const pending = pendings[i];
727
+ const history: HistoryTransaction = {
728
+ digest: pending.digest,
729
+ payload: pending.payload,
730
+ msafeAddress: pending.msafeAddress,
731
+ sequenceNumber: pending.sequenceNumber,
732
+ isRejectTx: pending.isRejectTx,
733
+ creator: pending.creator,
734
+ createdAt: pending.createdAt as Date,
735
+ // TODO: Fill in this field based on transaction processing result
736
+ status: pending.digest === digest ? executionResult : 'rejected',
737
+ };
738
+ await this.model.historyTransaction.save(history);
739
+ await this.model.pendingTransaction.delete({ digest: pending.digest });
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Build the next transaction intention, and add the built transaction to pendings.
745
+ * @param input
746
+ */
747
+ async buildNextIntentionAndAddToPending(input: { msafeAddress: string }) {
748
+ const maxSNHistory = await this.model.historyTransaction.findOne({
749
+ where: { msafeAddress: input.msafeAddress },
750
+ order: { sequenceNumber: 'desc' },
751
+ });
752
+ const nextSequenceNumber = maxSNHistory ? maxSNHistory.sequenceNumber + 1 : 0;
753
+
754
+ const currentPendings = await this.model.pendingTransaction.findBy({
755
+ msafeAddress: input.msafeAddress,
756
+ });
757
+ if (currentPendings.length !== 0) {
758
+ throw new Error('Still have pendings');
759
+ }
760
+
761
+ const nextTx = await this.model.transactionIntention.findOneBy({
762
+ msafeAddress: input.msafeAddress,
763
+ sequenceNumber: nextSequenceNumber,
764
+ });
765
+ if (nextTx === null) {
766
+ throw new Error('No future intentions to build');
767
+ }
768
+
769
+ const nextIntention = IntentionHelper.de(nextTx.data);
770
+ try {
771
+ const newTxb = await IntentionHelper.buildTxb({
772
+ suiClient: this._suiClient,
773
+ sender: input.msafeAddress,
774
+ intention: nextIntention,
775
+ });
776
+ const payload = await newTxb.build({ client: this._suiClient });
777
+ const newDigest = await newTxb.getDigest({ client: this._suiClient });
778
+ const newPending: PendingTransaction = {
779
+ digest: newDigest,
780
+ payload: Uint8ArrayToHex(payload),
781
+ msafeAddress: input.msafeAddress,
782
+ sequenceNumber: nextTx.sequenceNumber,
783
+ isRejectTx: false,
784
+ creator: nextTx.creator,
785
+ };
786
+ await this.model.pendingTransaction.save(newPending);
787
+ } catch (e: unknown) {
788
+ // Build failed. Mark the intention as failed.
789
+ await this.model.transactionIntention.update(
790
+ {
791
+ msafeAddress: input.msafeAddress,
792
+ sequenceNumber: nextSequenceNumber,
793
+ },
794
+ {
795
+ status: 'failed',
796
+ statusRemark: (e as any).toString(),
797
+ },
798
+ );
799
+ throw new Error(`Intention build failed: {sequenceNumber: ${nextSequenceNumber}}`);
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Skip next failed transaction if build of the intention has been failed before.
805
+ */
806
+ async skipNextFailedIntention(input: { msafeAddress: string; userAddress: string }) {
807
+ const userMSafe = await this.model.userMSafe.findOneBy({
808
+ msafeAddress: input.msafeAddress,
809
+ userAddress: input.userAddress,
810
+ });
811
+ if (userMSafe === null) {
812
+ throw new Error('user does not have permission to MSafe');
813
+ }
814
+
815
+ const curSequenceNumber = await this.model.historyTransaction.findOne({
816
+ where: { msafeAddress: input.msafeAddress },
817
+ order: { sequenceNumber: 'desc' },
818
+ });
819
+ const nextSequenceNumber = curSequenceNumber ? curSequenceNumber.sequenceNumber + 1 : 0;
820
+
821
+ const failedIntention = await this.model.transactionIntention.findOneBy({
822
+ msafeAddress: input.msafeAddress,
823
+ sequenceNumber: nextSequenceNumber,
824
+ });
825
+ if (failedIntention === null) {
826
+ throw new Error('Next intention not found');
827
+ }
828
+ if (failedIntention.status !== 'failed') {
829
+ throw new Error('Next intention not failed');
830
+ }
831
+
832
+ const history: HistoryTransaction = {
833
+ msafeAddress: input.msafeAddress,
834
+ digest: '0x0', // Special case for build has been failed,
835
+ payload: '',
836
+ sequenceNumber: nextSequenceNumber,
837
+ creator: failedIntention.creator,
838
+ isRejectTx: false,
839
+ status: 'build-failed',
840
+ };
841
+ await this.model.historyTransaction.save(history);
842
+ }
843
+
844
+ private async getUser(address: string): Promise<User | null> {
845
+ return this.model.user.findOneBy({ address });
846
+ }
847
+
848
+ private get model() {
849
+ return this.db.coreModel;
850
+ }
851
+ }