📄 src/web3/vue/MutationBus.ts
D-OPEN SOVEREIGN
1import { reactive, readonly } from 'vue';
2import type { WalletType } from '../client/types';
3
4export type ChainMutation =
5  | { type: 'retire'; wallet: WalletType; chainId: number; account: string }
6  | { type: 'vote'; wallet: WalletType; chainId: number; account: string; projectId: number; lang: string; amount: number }
7  | { type: 'save'; wallet: WalletType; chainId: number; account: string; newIndex: number }
8  | { type: 'subscribe'; wallet: WalletType; chainId: number; account: string; subscriber: number; target: number }
9  | { type: 'unsubscribe'; wallet: WalletType; chainId: number; account: string; subscriber: number; target: number };
10
11export interface MutationHandler<T extends ChainMutation> {
12  applyOptimistic: (mutation: T) => any;
13  rollback: (snapshot: any) => void;
14}
15
16const handlers = new Map<string, MutationHandler<any>>();
17
18export function registerHandler<T extends ChainMutation>(type: T['type'], handler: MutationHandler<T>) {
19  handlers.set(type, handler);
20}
21
22export type PendingStatus = 'awaiting_signature' | 'broadcasting';
23export interface PendingMutation {
24  id: string;
25  label: string;
26  status: PendingStatus;
27}
28
29export interface MutationError {
30  id: string;
31  label: string;
32  reason: 'rejected' | 'failed';
33}
34
35const pendingMutations = reactive<PendingMutation[]>([]);
36const mutationErrors = reactive<MutationError[]>([]);
37
38let idCounter = 0;
39
40export interface MutationBusType {
41  pending: ReadonlyArray<PendingMutation>;
42  errors: ReadonlyArray<MutationError>;
43  clearError: (id: string) => void;
44  dispatch: <T extends ChainMutation>(
45    mutation: T,
46    commit: (onAccepted: () => void) => Promise<boolean | void>,
47    label: string
48  ) => Promise<boolean>;
49}
50
51export const mutationBus: MutationBusType = {
52  pending: readonly(pendingMutations) as unknown as ReadonlyArray<PendingMutation>,
53  errors: readonly(mutationErrors) as unknown as ReadonlyArray<MutationError>,
54
55  clearError(id: string) {
56    const idx = mutationErrors.findIndex((e) => e.id === id);
57    if (idx !== -1) mutationErrors.splice(idx, 1);
58  },
59
60  async dispatch<T extends ChainMutation>(
61    mutation: T,
62    commit: (onAccepted: () => void) => Promise<boolean | void>,
63    label: string
64  ): Promise<boolean> {
65    const id = `mut_${++idCounter}`;
66    const initialStatus = mutation.wallet === 'private_key' ? 'broadcasting' : 'awaiting_signature';
67
68    pendingMutations.push({ id, label, status: initialStatus });
69
70    const handler = handlers.get(mutation.type);
71    let snapshot: any = null;
72
73    try {
74      if (handler) {
75        snapshot = handler.applyOptimistic(mutation);
76      }
77
78      const onAccepted = () => {
79        const item = pendingMutations.find((p) => p.id === id);
80        if (item) item.status = 'broadcasting';
81      };
82
83      const result = await commit(onAccepted);
84
85      if (result === false) {
86        throw new Error('Transaction failed');
87      }
88
89      return true;
90    } catch (e: any) {
91      if (handler && snapshot) {
92        try {
93          handler.rollback(snapshot);
94        } catch (rollbackError) {
95          console.error('Rollback failed:', rollbackError);
96        }
97      }
98
99      const isRejected =
100        e.code === 4001 ||
101        e.code === 'ACTION_REJECTED' ||
102        (e.message && (e.message.toLowerCase().includes('user rejected') || e.message.toLowerCase().includes('cancelled')));
103
104      mutationErrors.push({
105        id,
106        label,
107        reason: isRejected ? 'rejected' : 'failed',
108      });
109
110      return false;
111    } finally {
112      const idx = pendingMutations.findIndex((p) => p.id === id);
113      if (idx !== -1) pendingMutations.splice(idx, 1);
114    }
115  },
116};
117