Skip to content

Commit fb95327

Browse files
committed
feat: implement on/off event methods in cip30.experimental
1 parent 15b68e6 commit fb95327

File tree

10 files changed

+268
-5
lines changed

10 files changed

+268
-5
lines changed

packages/dapp-connector/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@cardano-sdk/core": "workspace:~",
5454
"@cardano-sdk/crypto": "workspace:~",
5555
"@cardano-sdk/util": "workspace:~",
56+
"rxjs": "^7.4.0",
5657
"ts-custom-error": "^3.2.0",
5758
"ts-log": "^2.2.4",
5859
"webextension-polyfill": "^0.8.0"
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Cardano } from '@cardano-sdk/core';
2+
import { Observable, Subscription } from 'rxjs';
3+
4+
export type AccountChangeCb = (addresses: Cardano.BaseAddress[]) => unknown;
5+
export type NetworkChangeCb = (network: Cardano.NetworkId) => unknown;
6+
export enum Cip30EventName {
7+
'accountChange' = 'accountChange',
8+
'networkChange' = 'networkChange'
9+
}
10+
export type Cip30EventMethod = (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) => void;
11+
export type Cip30Event = { eventName: Cip30EventName; data: Cardano.NetworkId | Cardano.BaseAddress[] };
12+
type Cip30NetworkChangeEvent = { eventName: Cip30EventName.networkChange; data: Cardano.NetworkId };
13+
type Cip30AccountChangeEvent = { eventName: Cip30EventName.accountChange; data: Cardano.BaseAddress[] };
14+
type Cip30EventRegistryMap = {
15+
accountChange: AccountChangeCb[];
16+
networkChange: NetworkChangeCb[];
17+
};
18+
19+
const isNetworkChangeEvent = (event: Cip30Event): event is Cip30NetworkChangeEvent =>
20+
event.eventName === Cip30EventName.networkChange;
21+
22+
const isAccountChangeEvent = (event: Cip30Event): event is Cip30AccountChangeEvent =>
23+
event.eventName === Cip30EventName.accountChange;
24+
25+
/**
26+
* This class is responsible for registering and deregistering callbacks for specific events.
27+
* It also handles calling the registered callbacks.
28+
*/
29+
export class Cip30EventRegistry {
30+
#cip30Event$: Observable<Cip30Event>;
31+
#registry: Cip30EventRegistryMap;
32+
#subscription: Subscription;
33+
34+
constructor(cip30Event$: Observable<Cip30Event>) {
35+
this.#cip30Event$ = cip30Event$;
36+
this.#registry = {
37+
accountChange: [],
38+
networkChange: []
39+
};
40+
41+
this.#subscription = this.#cip30Event$.subscribe((event) => {
42+
if (isNetworkChangeEvent(event)) {
43+
const { data } = event;
44+
for (const callback of this.#registry.networkChange) callback(data);
45+
} else if (isAccountChangeEvent(event)) {
46+
const { data } = event;
47+
for (const callback of this.#registry.accountChange) callback(data);
48+
}
49+
});
50+
}
51+
52+
/**
53+
* Register a callback for a specific event name.
54+
*
55+
* @param eventName - The event name to register the callback for.
56+
* @param callback - The callback to be called when the event is triggered.
57+
*/
58+
register(eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) {
59+
if (this.#subscription.closed) return;
60+
61+
if (eventName === Cip30EventName.accountChange) {
62+
this.#registry.accountChange.push(callback as AccountChangeCb);
63+
} else if (eventName === Cip30EventName.networkChange) {
64+
this.#registry.networkChange.push(callback as NetworkChangeCb);
65+
}
66+
}
67+
68+
/**
69+
* Deregister a callback for a specific event name. The callback must be the same reference used on registration.
70+
*
71+
* @param eventName - The event name to deregister the callback from.
72+
* @param callback - The callback to be deregistered.
73+
*/
74+
deregister(eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) {
75+
if (this.#subscription.closed) return;
76+
77+
if (eventName === Cip30EventName.accountChange) {
78+
this.#registry.accountChange = this.#registry.accountChange.filter((cb) => cb !== callback);
79+
} else if (eventName === Cip30EventName.networkChange) {
80+
this.#registry.networkChange = this.#registry.networkChange.filter((cb) => cb !== callback);
81+
}
82+
}
83+
84+
/** Unsubscribe from the event stream. Once called, the registry can no longer be used. */
85+
shutdown() {
86+
if (this.#subscription.closed) return;
87+
this.#subscription.unsubscribe();
88+
}
89+
}

packages/dapp-connector/src/WalletApi/Cip30Wallet.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { APIErrorCode, ApiError } from '../errors';
2+
import { AccountChangeCb, Cip30EventName, Cip30EventRegistry, NetworkChangeCb } from './Cip30EventRegistry';
23
import {
34
Bytes,
45
Cbor,
@@ -12,6 +13,7 @@ import {
1213
import { Cardano } from '@cardano-sdk/core';
1314
import { Logger } from 'ts-log';
1415
import { RemoteAuthenticator } from '../AuthenticatorApi';
16+
import { map, merge } from 'rxjs';
1517

1618
export const CipMethodsMapping: Record<number, WalletMethod[]> = {
1719
30: [
@@ -79,6 +81,7 @@ export class Cip30Wallet {
7981
readonly #api: WalletApi;
8082
readonly #authenticator: RemoteAuthenticator;
8183
readonly #deviations: WalletProperties['cip30ApiDeviations'];
84+
#eventRegistry: Cip30EventRegistry;
8285

8386
constructor(properties: WalletProperties, { api, authenticator, logger }: WalletDependencies) {
8487
this.icon = properties.icon;
@@ -92,6 +95,12 @@ export class Cip30Wallet {
9295
if (properties.supportedExtensions) {
9396
this.supportedExtensions = properties.supportedExtensions;
9497
}
98+
this.#eventRegistry = new Cip30EventRegistry(
99+
merge(
100+
api.network$.pipe(map((data) => ({ data, eventName: Cip30EventName.networkChange }))),
101+
api.baseAddresses$.pipe(map((data) => ({ data, eventName: Cip30EventName.accountChange })))
102+
)
103+
);
95104
}
96105

97106
#validateExtensions(extensions: WalletApiExtension[] = []): void {
@@ -164,7 +173,25 @@ export class Cip30Wallet {
164173
const baseApi: Cip30WalletApiWithPossibleExtensions = {
165174
// Add experimental.getCollateral to CIP-30 API
166175
experimental: {
167-
getCollateral: async (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params)
176+
getCollateral: async (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params),
177+
178+
/**
179+
* Deregister the callback from the event.
180+
*
181+
* @param {EventName} eventName The event to deregister from. Accepted values are 'accountChange' | 'networkChange'
182+
* @param {AccountChangeCb | NetworkChangeCb} callback Must be the same cb reference used on registration.
183+
*/
184+
off: (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) =>
185+
this.#eventRegistry.deregister(eventName, callback),
186+
187+
/**
188+
* Register to events coming from the wallet. Registrations are stored by callback reference.
189+
*
190+
* @param {EventName} eventName The event to register to. Accepted values are 'accountChange' | 'networkChange'
191+
* @param {AccountChangeCb | NetworkChangeCb} callback The callback to be called when the event is triggered.
192+
*/
193+
on: (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) =>
194+
this.#eventRegistry.register(eventName, callback)
168195
},
169196
getBalance: () => walletApi.getBalance(),
170197
getChangeAddress: () => walletApi.getChangeAddress(),

packages/dapp-connector/src/WalletApi/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Cardano } from '@cardano-sdk/core';
22
import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
33
import { HexBlob } from '@cardano-sdk/util';
4+
import { Observable } from 'rxjs';
45
import { Runtime } from 'webextension-polyfill';
56

67
/** A hex-encoded string of the corresponding bytes. */
@@ -199,13 +200,18 @@ export interface Cip30WalletApi {
199200
experimental?: any;
200201
}
201202

203+
export interface Cip30ExperimentalApi {
204+
network$: Observable<Cardano.NetworkId>;
205+
baseAddresses$: Observable<Cardano.BaseAddress[]>;
206+
}
207+
202208
export interface Cip95WalletApi {
203209
getRegisteredPubStakeKeys: () => Promise<Ed25519PublicKeyHex[]>;
204210
getUnregisteredPubStakeKeys: () => Promise<Ed25519PublicKeyHex[]>;
205211
getPubDRepKey: () => Promise<Ed25519PublicKeyHex>;
206212
}
207213

208-
export type WalletApi = Cip30WalletApi & Cip95WalletApi;
214+
export type WalletApi = Cip30WalletApi & Cip30ExperimentalApi & Cip95WalletApi;
209215
export type WalletMethod = keyof WalletApi;
210216

211217
export interface CipExtensionApis {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Cardano } from '@cardano-sdk/core';
2+
import { Cip30Event, Cip30EventName, Cip30EventRegistry } from '../../src/WalletApi/Cip30EventRegistry';
3+
import { Subject } from 'rxjs';
4+
5+
describe('Cip30EventRegistry', () => {
6+
let cip30Event$: Subject<Cip30Event>;
7+
let registry: Cip30EventRegistry;
8+
9+
beforeEach(() => {
10+
cip30Event$ = new Subject();
11+
registry = new Cip30EventRegistry(cip30Event$);
12+
});
13+
14+
afterEach(() => {
15+
registry.shutdown();
16+
});
17+
18+
it('should register and trigger networkChange callback', () => {
19+
const callback = jest.fn();
20+
registry.register(Cip30EventName.networkChange, callback);
21+
22+
const networkId: Cardano.NetworkId = 1;
23+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
24+
25+
expect(callback).toHaveBeenCalledWith(networkId);
26+
});
27+
28+
it('should register and trigger accountChange callback', () => {
29+
const callback = jest.fn();
30+
registry.register(Cip30EventName.accountChange, callback);
31+
32+
const addresses: Cardano.BaseAddress[] = [{} as unknown as Cardano.BaseAddress];
33+
cip30Event$.next({ data: addresses, eventName: Cip30EventName.accountChange });
34+
35+
expect(callback).toHaveBeenCalledWith(addresses);
36+
});
37+
38+
it('should deregister networkChange callback', () => {
39+
const callback = jest.fn();
40+
registry.register(Cip30EventName.networkChange, callback);
41+
registry.deregister(Cip30EventName.networkChange, callback);
42+
43+
const networkId: Cardano.NetworkId = 1;
44+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
45+
46+
expect(callback).not.toHaveBeenCalled();
47+
});
48+
49+
it('should deregister accountChange callback', () => {
50+
const callback = jest.fn();
51+
registry.register(Cip30EventName.accountChange, callback);
52+
registry.deregister(Cip30EventName.accountChange, callback);
53+
54+
const addresses: Cardano.BaseAddress[] = [{} as unknown as Cardano.BaseAddress];
55+
cip30Event$.next({ data: addresses, eventName: Cip30EventName.accountChange });
56+
57+
expect(callback).not.toHaveBeenCalled();
58+
});
59+
60+
it('should handle multiple callbacks for the same event', () => {
61+
const callback1 = jest.fn();
62+
const callback2 = jest.fn();
63+
registry.register(Cip30EventName.networkChange, callback1);
64+
registry.register(Cip30EventName.networkChange, callback2);
65+
66+
const networkId: Cardano.NetworkId = 1;
67+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
68+
69+
expect(callback1).toHaveBeenCalledWith(networkId);
70+
expect(callback2).toHaveBeenCalledWith(networkId);
71+
});
72+
73+
it('should not trigger callbacks after shutdown', () => {
74+
const callback = jest.fn();
75+
registry.register(Cip30EventName.networkChange, callback);
76+
77+
registry.shutdown();
78+
79+
const networkId: Cardano.NetworkId = 1;
80+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
81+
82+
expect(callback).not.toHaveBeenCalled();
83+
expect(cip30Event$.observed).toBeFalsy();
84+
});
85+
86+
it('should not register callbacks after shutdown', () => {
87+
const callback = jest.fn();
88+
registry.shutdown();
89+
registry.register(Cip30EventName.networkChange, callback);
90+
91+
const networkId: Cardano.NetworkId = 1;
92+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
93+
94+
expect(callback).not.toHaveBeenCalled();
95+
});
96+
});

packages/dapp-connector/test/testWallet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { BehaviorSubject } from 'rxjs';
12
import { Cardano, Serialization } from '@cardano-sdk/core';
23
import { Cip30DataSignature, WalletApi, WalletProperties } from '../src/WalletApi';
34
import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
@@ -32,6 +33,7 @@ export const api = <WalletApi>{
3233
}
3334
]).toCbor()
3435
],
36+
network$: new BehaviorSubject(Cardano.NetworkId.Mainnet),
3537
signData: async (_addr, _payload) => ({} as Cip30DataSignature),
3638
signTx: async (_tx) => 'signedTransaction',
3739
submitTx: async (_tx) => 'transactionId'

packages/wallet/src/cip30.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Bytes,
55
Cbor,
66
Cip30DataSignature,
7+
Cip30ExperimentalApi,
78
Cip95WalletApi,
89
DataSignError,
910
DataSignErrorCode,
@@ -18,11 +19,22 @@ import {
1819
WithSenderContext
1920
} from '@cardano-sdk/dapp-connector';
2021
import { Cardano, Serialization, coalesceValueQuantities } from '@cardano-sdk/core';
21-
import { HexBlob, ManagedFreeableScope } from '@cardano-sdk/util';
22+
import { HexBlob, ManagedFreeableScope, isNotNil } from '@cardano-sdk/util';
2223
import { InputSelectionError, InputSelectionFailure } from '@cardano-sdk/input-selection';
2324
import { Logger } from 'ts-log';
2425
import { MessageSender } from '@cardano-sdk/key-management';
25-
import { Observable, firstValueFrom, from, map, mergeMap, race, throwError } from 'rxjs';
26+
import {
27+
Observable,
28+
distinctUntilChanged,
29+
firstValueFrom,
30+
from,
31+
map,
32+
mergeMap,
33+
race,
34+
switchMap,
35+
take,
36+
throwError
37+
} from 'rxjs';
2638
import { ObservableWallet } from './types';
2739
import { requiresForeignSignatures } from './services';
2840
import uniq from 'lodash/uniq.js';
@@ -555,6 +567,22 @@ const baseCip30WalletApi = (
555567
}
556568
});
557569

570+
const cip30ExperimentalWalletApi = (wallet$: Observable<ObservableWallet>): Cip30ExperimentalApi => ({
571+
baseAddresses$: wallet$.pipe(
572+
/**
573+
* Using take(1) to emit baseAddresses only when the wallet changes,
574+
* which is equivalent to account change, instead of every time the addresses change.
575+
*/
576+
switchMap((wallet) => wallet.addresses$.pipe(take(1))),
577+
map((addresses) => addresses.map(({ address }) => Cardano.Address.fromBech32(address).asBase()).filter(isNotNil))
578+
),
579+
network$: wallet$.pipe(
580+
switchMap((wallet) => wallet.genesisParameters$),
581+
map((params) => params.networkId),
582+
distinctUntilChanged()
583+
)
584+
});
585+
558586
const getPubStakeKeys = async (
559587
wallet$: Observable<ObservableWallet>,
560588
filter: Cardano.StakeCredentialStatus.Registered | Cardano.StakeCredentialStatus.Unregistered
@@ -621,5 +649,6 @@ export const createWalletApi = (
621649
{ logger }: Cip30WalletDependencies
622650
): WithSenderContext<WalletApi> => ({
623651
...baseCip30WalletApi(wallet$, confirmationCallback, { logger }),
652+
...cip30ExperimentalWalletApi(wallet$),
624653
...extendedCip95WalletApi(wallet$, { logger })
625654
});

packages/wallet/test/integration/cip30mapping.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,16 @@ describe('cip30', () => {
757757
const extensions = await api.getExtensions(context);
758758
expect(extensions).toEqual([{ cip: 95 }]);
759759
});
760+
761+
test('api.baseAddresses', async () => {
762+
const baseAddresses = await firstValueFrom(api.baseAddresses$);
763+
expect(baseAddresses.length).toBe(2);
764+
});
765+
766+
test('api.network', async () => {
767+
const network = await firstValueFrom(api.network$);
768+
expect(network).toEqual(Cardano.NetworkId.Testnet);
769+
});
760770
});
761771

762772
describe('confirmation callbacks', () => {

yarn-project.nix

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ cacheEntries = {
441441
"@emurgo/cip14-js@npm:3.0.1" = { filename = "@emurgo-cip14-js-npm-3.0.1-6011030ea2-9eaf312410.zip"; sha512 = "9eaf3124108e8c252a745de9ef1f334ab26a32271077b00fe0ea2a06e40838dd435165dac523ebd4d851ae7a94d8c56766dabc372aabffedd36551c798c607c5"; };
442442
"@endemolshinegroup/cosmiconfig-typescript-loader@npm:3.0.2" = { filename = "@endemolshinegroup-cosmiconfig-typescript-loader-npm-3.0.2-97436e68fc-7fe0198622.zip"; sha512 = "7fe0198622b1063c40572034df7e8ba867865a1b4815afe230795929abcf785758b34d7806a8e2100ba8ab4e92c5a1c3e11a980c466c4406df6e7ec6e50df8b6"; };
443443
"@es-joy/jsdoccomment@npm:0.10.8" = { filename = "@es-joy-jsdoccomment-npm-0.10.8-d03c65b162-3e144ef393.zip"; sha512 = "3e144ef393459a541b64f6c9c8e62fb6d9b47e1a2c626410487ede12c472064f6ce6e0911df60b42ccf126d5a66102707eef59ca14767cb7aeb5e608b227558d"; };
444-
"@esbuild/linux-x64@npm:0.21.5" = { filename = "@esbuild-linux-x64-npm-0.21.5-88079726c4-8.zip"; sha512 = "91c202dca064909b2c56522f98e3a3b24bc5d43405506b4e67923ecb5d0cc2b78dcee8d815f705d71395402f8532670a391777a3cf6a08894049e453becf07a0"; };
444+
"@esbuild/darwin-arm64@npm:0.21.5" = { filename = "@esbuild-darwin-arm64-npm-0.21.5-62349c1520-8.zip"; sha512 = "50d5d633be3d0fe0fce54c4740171ae6d2e8f5220280a6f6996f234c718de25535e50a31cee1745b5b80f2cc9e336c42c7fc2b49f3ea38b5f3ff5d8c97ef4123"; };
445445
"@eslint/eslintrc@npm:0.4.3" = { filename = "@eslint-eslintrc-npm-0.4.3-ee1bbcab87-03a7704150.zip"; sha512 = "03a7704150b868c318aab6a94d87a33d30dc2ec579d27374575014f06237ba1370ae11178db772f985ef680d469dc237e7b16a1c5d8edaaeb8c3733e7a95a6d3"; };
446446
"@ethereumjs/common@npm:4.4.0" = { filename = "@ethereumjs-common-npm-4.4.0-ee991f5124-6b8cbfcfb5.zip"; sha512 = "6b8cbfcfb5bdde839545c89dce3665706733260e26455d0eb3bcbc3c09e371ae629d51032b95d86f2aeeb15325244a6622171f9005165266fefd923eaa99f1c5"; };
447447
"@ethereumjs/rlp@npm:5.0.2" = { filename = "@ethereumjs-rlp-npm-5.0.2-72fb389b37-b569061ddb.zip"; sha512 = "b569061ddb1f4cf56a82f7a677c735ba37f9e94e2bbaf567404beb9e2da7aa1f595e72fc12a17c61f7aec67fd5448443efe542967c685a2fe0ffc435793dcbab"; };
@@ -1462,6 +1462,8 @@ cacheEntries = {
14621462
"fs.realpath@npm:1.0.0" = { filename = "fs.realpath-npm-1.0.0-c8f05d8126-99ddea01a7.zip"; sha512 = "99ddea01a7e75aa276c250a04eedeffe5662bce66c65c07164ad6264f9de18fb21be9433ead460e54cff20e31721c811f4fb5d70591799df5f85dce6d6746fd0"; };
14631463
"fsevents@npm:2.3.2" = { filename = "fsevents-npm-2.3.2-a881d6ac9f-97ade64e75.zip"; sha512 = "97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f"; };
14641464
"fsevents@npm:2.3.3" = { filename = "fsevents-npm-2.3.3-ce9fb0ffae-11e6ea6fea.zip"; sha512 = "11e6ea6fea15e42461fc55b4b0e4a0a3c654faa567f1877dbd353f39156f69def97a69936d1746619d656c4b93de2238bf731f6085a03a50cabf287c9d024317"; };
1465+
"fsevents@patch:fsevents@npm%3A2.3.2#~builtin<compat/fsevents>::version=2.3.2&hash=18f3a7" = { filename = "fsevents-patch-3340e2eb10-8.zip"; sha512 = "edbd0fd80be379c14409605f77e52fdc78a119e17f875e8b90a220c3e5b29e54a1477c21d91fd30b957ea4866406dc3ff87b61432d2840ff8866b309e5866140"; };
1466+
"fsevents@patch:fsevents@npm%3A2.3.3#~builtin<compat/fsevents>::version=2.3.3&hash=18f3a7" = { filename = "fsevents-patch-7934e3c202-8.zip"; sha512 = "4639e24e2774cbd3669bd08521e0eeeb9d05bbabffdfdee418cc75a237660bc2fb30520a266ad5379199e2d657f430dd4236ad3642674ef32f20cc7258506725"; };
14651467
"ftp@npm:0.3.10" = { filename = "ftp-npm-0.3.10-348fb9ac23-ddd313c1d4.zip"; sha512 = "ddd313c1d44eb7429f3a7d77a0155dc8fe86a4c64dca58f395632333ce4b4e74c61413c6e0ef66ea3f3d32d905952fbb6d028c7117d522f793eb1fa282e17357"; };
14661468
"function-bind@npm:1.1.1" = { filename = "function-bind-npm-1.1.1-b56b322ae9-b32fbaebb3.zip"; sha512 = "b32fbaebb3f8ec4969f033073b43f5c8befbb58f1a79e12f1d7490358150359ebd92f49e72ff0144f65f2c48ea2a605bff2d07965f548f6474fd8efd95bf361a"; };
14671469
"function.prototype.name@npm:1.1.5" = { filename = "function.prototype.name-npm-1.1.5-e776a642bb-acd21d733a.zip"; sha512 = "acd21d733a9b649c2c442f067567743214af5fa248dbeee69d8278ce7df3329ea5abac572be9f7470b4ec1cd4d8f1040e3c5caccf98ebf2bf861a0deab735c27"; };

0 commit comments

Comments
 (0)