@cartridge/controller 0.3.46 → 0.4.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.
@@ -0,0 +1,271 @@
1
+ import { AccountInterface, addAddressPadding } from "starknet";
2
+ import { AsyncMethodReturns } from "@cartridge/penpal";
3
+
4
+ import DeviceAccount from "./device";
5
+ import {
6
+ Keychain,
7
+ Policy,
8
+ ResponseCodes,
9
+ ConnectReply,
10
+ ProbeReply,
11
+ ControllerOptions,
12
+ ConnectError,
13
+ Profile,
14
+ IFrames,
15
+ ProfileContextTypeVariant,
16
+ } from "./types";
17
+ import { KeychainIFrame, ProfileIFrame } from "./iframe";
18
+ import { NotReadyToConnect, ProfileNotReady } from "./errors";
19
+ import { RPC_SEPOLIA } from "./constants";
20
+
21
+ export * from "./errors";
22
+ export * from "./types";
23
+ export { defaultPresets } from "./presets";
24
+
25
+ export default class Controller {
26
+ private policies: Policy[];
27
+ private keychain?: AsyncMethodReturns<Keychain>;
28
+ private profile?: AsyncMethodReturns<Profile>;
29
+ private options: ControllerOptions;
30
+ private iframes: IFrames;
31
+ public rpc: URL;
32
+ public account?: AccountInterface;
33
+
34
+ constructor({
35
+ policies,
36
+ url,
37
+ rpc,
38
+ paymaster,
39
+ ...options
40
+ }: ControllerOptions = {}) {
41
+ this.iframes = {
42
+ keychain: new KeychainIFrame({
43
+ ...options,
44
+ url,
45
+ paymaster,
46
+ onClose: this.keychain?.reset,
47
+ onConnect: (keychain) => {
48
+ this.keychain = keychain;
49
+ },
50
+ }),
51
+ };
52
+
53
+ this.rpc = new URL(rpc || RPC_SEPOLIA);
54
+
55
+ // TODO: remove this on the next major breaking change. pass everthing by url
56
+ this.policies =
57
+ policies?.map((policy) => ({
58
+ ...policy,
59
+ target: addAddressPadding(policy.target),
60
+ })) || [];
61
+
62
+ this.options = options;
63
+ }
64
+
65
+ async openSettings() {
66
+ if (!this.keychain || !this.iframes.keychain) {
67
+ console.error(new NotReadyToConnect().message);
68
+ return null;
69
+ }
70
+ this.iframes.keychain.open();
71
+ const res = await this.keychain.openSettings();
72
+ this.iframes.keychain.close();
73
+ if (res && (res as ConnectError).code === ResponseCodes.NOT_CONNECTED) {
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ ready() {
80
+ return this.probe().then(
81
+ (res) => !!res,
82
+ () => false,
83
+ );
84
+ }
85
+
86
+ async probe() {
87
+ try {
88
+ await this.waitForKeychain();
89
+
90
+ if (!this.keychain) {
91
+ console.error(new NotReadyToConnect().message);
92
+ return null;
93
+ }
94
+
95
+ const response = (await this.keychain.probe(
96
+ this.rpc.toString(),
97
+ )) as ProbeReply;
98
+
99
+ this.account = new DeviceAccount(
100
+ this.rpc.toString(),
101
+ response.address,
102
+ this.keychain,
103
+ this.options,
104
+ this.iframes.keychain,
105
+ ) as AccountInterface;
106
+ } catch (e) {
107
+ console.log(e);
108
+ console.error(new NotReadyToConnect().message);
109
+ return;
110
+ }
111
+
112
+ if (
113
+ this.options.profileUrl &&
114
+ this.options.indexerUrl &&
115
+ !this.iframes.profile
116
+ ) {
117
+ const username = await this.keychain.username();
118
+ this.iframes.profile = new ProfileIFrame({
119
+ profileUrl: this.options.profileUrl,
120
+ indexerUrl: this.options.indexerUrl,
121
+ address: this.account.address,
122
+ username,
123
+ rpcUrl: this.rpc.toString(),
124
+ tokens: this.options.tokens,
125
+ onConnect: (profile) => {
126
+ this.profile = profile;
127
+ },
128
+ });
129
+ }
130
+
131
+ return !!this.account;
132
+ }
133
+
134
+ async connect() {
135
+ if (this.account) {
136
+ return this.account;
137
+ }
138
+
139
+ if (!this.keychain || !this.iframes.keychain) {
140
+ console.error(new NotReadyToConnect().message);
141
+ return;
142
+ }
143
+
144
+ if (!!document.hasStorageAccess) {
145
+ const ok = await document.hasStorageAccess();
146
+ if (!ok) {
147
+ await document.requestStorageAccess();
148
+ }
149
+ }
150
+
151
+ this.iframes.keychain.open();
152
+
153
+ try {
154
+ let response = await this.keychain.connect(
155
+ this.policies,
156
+ this.rpc.toString(),
157
+ );
158
+ if (response.code !== ResponseCodes.SUCCESS) {
159
+ throw new Error(response.message);
160
+ }
161
+
162
+ response = response as ConnectReply;
163
+ this.account = new DeviceAccount(
164
+ this.rpc.toString(),
165
+ response.address,
166
+ this.keychain,
167
+ this.options,
168
+ this.iframes.keychain,
169
+ ) as AccountInterface;
170
+
171
+ return this.account;
172
+ } catch (e) {
173
+ console.log(e);
174
+ } finally {
175
+ this.iframes.keychain.close();
176
+ }
177
+ }
178
+
179
+ openProfile(tab: ProfileContextTypeVariant = "inventory") {
180
+ if (!this.options.indexerUrl) {
181
+ console.error("`indexerUrl` option is required to open profile");
182
+ return;
183
+ }
184
+ if (!this.profile || !this.iframes.profile) {
185
+ console.error(new ProfileNotReady().message);
186
+ return;
187
+ }
188
+
189
+ this.profile.navigate(tab);
190
+ this.iframes.profile.open();
191
+ }
192
+
193
+ async disconnect() {
194
+ if (!this.keychain) {
195
+ console.error(new NotReadyToConnect().message);
196
+ return;
197
+ }
198
+
199
+ if (!!document.hasStorageAccess) {
200
+ const ok = await document.hasStorageAccess();
201
+ if (!ok) {
202
+ await document.requestStorageAccess();
203
+ }
204
+ }
205
+
206
+ this.account = undefined;
207
+ return this.keychain.disconnect();
208
+ }
209
+
210
+ revoke(origin: string, _policy: Policy[]) {
211
+ if (!this.keychain) {
212
+ console.error(new NotReadyToConnect().message);
213
+ return null;
214
+ }
215
+
216
+ return this.keychain.revoke(origin);
217
+ }
218
+
219
+ username() {
220
+ if (!this.keychain) {
221
+ console.error(new NotReadyToConnect().message);
222
+ return;
223
+ }
224
+
225
+ return this.keychain.username();
226
+ }
227
+
228
+ fetchControllers(
229
+ contractAddresses: string[],
230
+ ): Promise<Record<string, string>> {
231
+ if (!this.keychain) {
232
+ throw new NotReadyToConnect().message;
233
+ }
234
+
235
+ return this.keychain.fetchControllers(contractAddresses);
236
+ }
237
+
238
+ async delegateAccount() {
239
+ if (!this.keychain) {
240
+ console.error(new NotReadyToConnect().message);
241
+ return null;
242
+ }
243
+
244
+ return await this.keychain.delegateAccount();
245
+ }
246
+
247
+ private waitForKeychain({
248
+ timeout = 5000,
249
+ interval = 100,
250
+ }:
251
+ | {
252
+ timeout?: number;
253
+ interval?: number;
254
+ }
255
+ | undefined = {}) {
256
+ return new Promise<void>((resolve, reject) => {
257
+ const startTime = Date.now();
258
+ const id = setInterval(() => {
259
+ if (Date.now() - startTime > timeout) {
260
+ clearInterval(id);
261
+ reject(new Error("Timeout waiting for keychain"));
262
+ return;
263
+ }
264
+ if (!this.keychain) return;
265
+
266
+ clearInterval(id);
267
+ resolve();
268
+ }, interval);
269
+ });
270
+ }
271
+ }
package/src/device.ts ADDED
@@ -0,0 +1,162 @@
1
+ import {
2
+ Account,
3
+ Abi,
4
+ Call,
5
+ EstimateFeeDetails,
6
+ Signature,
7
+ InvokeFunctionResponse,
8
+ EstimateFee,
9
+ DeclareContractPayload,
10
+ RpcProvider,
11
+ TypedData,
12
+ InvocationsDetails,
13
+ } from "starknet";
14
+
15
+ import {
16
+ ConnectError,
17
+ Keychain,
18
+ KeychainOptions,
19
+ Modal,
20
+ ResponseCodes,
21
+ } from "./types";
22
+ import { Signer } from "./signer";
23
+ import { AsyncMethodReturns } from "@cartridge/penpal";
24
+
25
+ class DeviceAccount extends Account {
26
+ address: string;
27
+ private keychain: AsyncMethodReturns<Keychain>;
28
+ private modal: Modal;
29
+ private options?: KeychainOptions;
30
+
31
+ constructor(
32
+ rpcUrl: string,
33
+ address: string,
34
+ keychain: AsyncMethodReturns<Keychain>,
35
+ options: KeychainOptions,
36
+ modal: Modal,
37
+ ) {
38
+ super(
39
+ new RpcProvider({ nodeUrl: rpcUrl }),
40
+ address,
41
+ new Signer(keychain, modal),
42
+ );
43
+ this.address = address;
44
+ this.keychain = keychain;
45
+ this.options = options;
46
+ this.modal = modal;
47
+ }
48
+
49
+ /**
50
+ * Estimate Fee for a method on starknet
51
+ *
52
+ * @param calls the invocation object containing:
53
+ * - contractAddress - the address of the contract
54
+ * - entrypoint - the entrypoint of the contract
55
+ * - calldata - (defaults to []) the calldata
56
+ * - signature - (defaults to []) the signature
57
+ *
58
+ * @returns response from addTransaction
59
+ */
60
+ async estimateInvokeFee(
61
+ calls: Call | Call[],
62
+ details?: EstimateFeeDetails,
63
+ ): Promise<EstimateFee> {
64
+ return this.keychain.estimateInvokeFee(calls, {
65
+ ...details,
66
+ });
67
+ }
68
+
69
+ async estimateDeclareFee(
70
+ payload: DeclareContractPayload,
71
+ details?: EstimateFeeDetails,
72
+ ): Promise<EstimateFee> {
73
+ return this.keychain.estimateDeclareFee(payload, {
74
+ ...details,
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Invoke execute function in account contract
80
+ *
81
+ * @param calls the invocation object or an array of them, containing:
82
+ * - contractAddress - the address of the contract
83
+ * - entrypoint - the entrypoint of the contract
84
+ * - calldata - (defaults to []) the calldata
85
+ * - signature - (defaults to []) the signature
86
+ * @param abis (optional) the abi of the contract for better displaying
87
+ *
88
+ * @returns response from addTransaction
89
+ */
90
+ // @ts-expect-error TODO: fix overload type mismatch
91
+ async execute(
92
+ calls: Call | Call[],
93
+ abis?: Abi[],
94
+ transactionsDetail: InvocationsDetails = {},
95
+ ): Promise<InvokeFunctionResponse> {
96
+ return new Promise(async (resolve, reject) => {
97
+ const sessionExecute = await this.keychain.execute(
98
+ calls,
99
+ abis,
100
+ transactionsDetail,
101
+ false,
102
+ this.options?.paymaster,
103
+ );
104
+
105
+ // Session call succeeded
106
+ if (sessionExecute.code === ResponseCodes.SUCCESS) {
107
+ resolve(sessionExecute as InvokeFunctionResponse);
108
+ return;
109
+ }
110
+
111
+ // Propagates session txn error back to caller
112
+ if (this.options?.propagateSessionErrors) {
113
+ reject((sessionExecute as ConnectError).error);
114
+ return;
115
+ }
116
+
117
+ // Session call or Paymaster flow failed.
118
+ // Session not avaialble, manual flow fallback
119
+ this.modal.open();
120
+ const manualExecute = await this.keychain.execute(
121
+ calls,
122
+ abis,
123
+ transactionsDetail,
124
+ true,
125
+ this.options?.paymaster,
126
+ (sessionExecute as ConnectError).error,
127
+ );
128
+
129
+ // Manual call succeeded
130
+ if (manualExecute.code === ResponseCodes.SUCCESS) {
131
+ resolve(manualExecute as InvokeFunctionResponse);
132
+ this.modal.close();
133
+ return;
134
+ }
135
+
136
+ reject((manualExecute as ConnectError).error);
137
+ return;
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Sign an JSON object for off-chain usage with the starknet private key and return the signature
143
+ * This adds a message prefix so it cant be interchanged with transactions
144
+ *
145
+ * @param json - JSON object to be signed
146
+ * @returns the signature of the JSON object
147
+ * @throws {Error} if the JSON object is not a valid JSON
148
+ */
149
+ async signMessage(typedData: TypedData): Promise<Signature> {
150
+ try {
151
+ this.modal.open();
152
+ const res = await this.keychain.signMessage(typedData, this.address);
153
+ this.modal.close();
154
+ return res as Signature;
155
+ } catch (e) {
156
+ console.error(e);
157
+ throw e;
158
+ }
159
+ }
160
+ }
161
+
162
+ export default DeviceAccount;
package/src/errors.ts ADDED
@@ -0,0 +1,15 @@
1
+ export class NotReadyToConnect extends Error {
2
+ constructor() {
3
+ super("Not ready to connect");
4
+
5
+ Object.setPrototypeOf(this, NotReadyToConnect.prototype);
6
+ }
7
+ }
8
+
9
+ export class ProfileNotReady extends Error {
10
+ constructor() {
11
+ super("Profile is not ready");
12
+
13
+ Object.setPrototypeOf(this, NotReadyToConnect.prototype);
14
+ }
15
+ }
@@ -0,0 +1,140 @@
1
+ import { AsyncMethodReturns, connectToChild } from "@cartridge/penpal";
2
+ import { defaultPresets } from "../presets";
3
+ import { ControllerOptions, Modal } from "../types";
4
+
5
+ export type IFrameOptions<CallSender> = Omit<
6
+ ConstructorParameters<typeof IFrame>[0],
7
+ "id" | "url" | "onConnect"
8
+ > & {
9
+ url?: string;
10
+ onConnect: (child: AsyncMethodReturns<CallSender>) => void;
11
+ };
12
+
13
+ export class IFrame<CallSender extends {}> implements Modal {
14
+ private iframe?: HTMLIFrameElement;
15
+ private container?: HTMLDivElement;
16
+ private onClose?: () => void;
17
+
18
+ constructor({
19
+ id,
20
+ url,
21
+ theme,
22
+ config,
23
+ colorMode,
24
+ onClose,
25
+ onConnect,
26
+ methods = {},
27
+ }: Pick<ControllerOptions, "theme" | "config" | "colorMode"> & {
28
+ id: string;
29
+ url: URL;
30
+ onClose?: () => void;
31
+ onConnect: (child: AsyncMethodReturns<CallSender>) => void;
32
+ methods?: { [key: string]: (...args: any[]) => void };
33
+ }) {
34
+ if (typeof document === "undefined") {
35
+ return;
36
+ }
37
+
38
+ url.searchParams.set(
39
+ "theme",
40
+ encodeURIComponent(
41
+ JSON.stringify(
42
+ config?.presets?.[theme ?? "cartridge"] ?? defaultPresets.cartridge,
43
+ ),
44
+ ),
45
+ );
46
+
47
+ if (colorMode) {
48
+ url.searchParams.set("colorMode", colorMode);
49
+ }
50
+
51
+ const iframe = document.createElement("iframe");
52
+ iframe.src = url.toString();
53
+ iframe.id = id;
54
+ iframe.style.border = "none";
55
+ iframe.sandbox.add("allow-forms");
56
+ iframe.sandbox.add("allow-popups");
57
+ iframe.sandbox.add("allow-scripts");
58
+ iframe.sandbox.add("allow-same-origin");
59
+ iframe.allow =
60
+ "publickey-credentials-create *; publickey-credentials-get *; clipboard-write";
61
+ if (!!document.hasStorageAccess) {
62
+ iframe.sandbox.add("allow-storage-access-by-user-activation");
63
+ }
64
+
65
+ const container = document.createElement("div");
66
+ container.style.position = "fixed";
67
+ container.style.height = "100%";
68
+ container.style.width = "100%";
69
+ container.style.top = "0";
70
+ container.style.left = "0";
71
+ container.style.zIndex = "10000";
72
+ container.style.backgroundColor = "rgba(0,0,0,0.6)";
73
+ container.style.display = "flex";
74
+ container.style.alignItems = "center";
75
+ container.style.justifyContent = "center";
76
+ container.style.visibility = "hidden";
77
+ container.style.opacity = "0";
78
+ container.style.transition = "opacity 0.2s ease";
79
+ container.appendChild(iframe);
80
+
81
+ this.iframe = iframe;
82
+ this.container = container;
83
+
84
+ connectToChild<CallSender>({
85
+ iframe: this.iframe,
86
+ methods: { close: () => this.close(), ...methods },
87
+ }).promise.then(onConnect);
88
+
89
+ this.resize();
90
+ window.addEventListener("resize", () => this.resize());
91
+
92
+ if (
93
+ document.readyState === "complete" ||
94
+ document.readyState === "interactive"
95
+ ) {
96
+ this.append();
97
+ } else {
98
+ document.addEventListener("DOMContentLoaded", this.append);
99
+ }
100
+
101
+ this.onClose = onClose;
102
+ }
103
+
104
+ open() {
105
+ if (!this.container) return;
106
+ document.body.style.overflow = "hidden";
107
+
108
+ this.container.style.visibility = "visible";
109
+ this.container.style.opacity = "1";
110
+ }
111
+
112
+ close() {
113
+ if (!this.container) return;
114
+ this.onClose?.();
115
+
116
+ document.body.style.overflow = "auto";
117
+
118
+ this.container.style.visibility = "hidden";
119
+ this.container.style.opacity = "0";
120
+ }
121
+
122
+ private append() {
123
+ if (!this.container) return;
124
+ document.body.appendChild(this.container);
125
+ }
126
+
127
+ private resize() {
128
+ if (!this.iframe) return;
129
+ if (window.innerWidth < 768) {
130
+ this.iframe.style.height = "100%";
131
+ this.iframe.style.width = "100%";
132
+ this.iframe.style.borderRadius = "0";
133
+ return;
134
+ }
135
+
136
+ this.iframe.style.height = "600px";
137
+ this.iframe.style.width = "432px";
138
+ this.iframe.style.borderRadius = "8px";
139
+ }
140
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./base";
2
+ export * from "./keychain";
3
+ export * from "./profile";
@@ -0,0 +1,34 @@
1
+ import { KEYCHAIN_URL } from "../constants";
2
+ import { Keychain, KeychainOptions } from "../types";
3
+ import { IFrame, IFrameOptions } from "./base";
4
+
5
+ type KeychainIframeOptions = IFrameOptions<Keychain> & KeychainOptions;
6
+
7
+ export class KeychainIFrame extends IFrame<Keychain> {
8
+ constructor({
9
+ url,
10
+ paymaster,
11
+ policies,
12
+ ...iframeOptions
13
+ }: KeychainIframeOptions) {
14
+ const _url = new URL(url ?? KEYCHAIN_URL);
15
+ if (paymaster) {
16
+ _url.searchParams.set(
17
+ "paymaster",
18
+ encodeURIComponent(JSON.stringify(paymaster)),
19
+ );
20
+ }
21
+ if (policies) {
22
+ _url.searchParams.set(
23
+ "policies",
24
+ encodeURIComponent(JSON.stringify(policies)),
25
+ );
26
+ }
27
+
28
+ super({
29
+ ...iframeOptions,
30
+ id: "controller-keychain",
31
+ url: _url,
32
+ });
33
+ }
34
+ }
@@ -0,0 +1,42 @@
1
+ import { PROFILE_URL } from "../constants";
2
+ import { Profile, ProfileOptions } from "../types";
3
+ import { IFrame, IFrameOptions } from "./base";
4
+
5
+ export type ProfileIFrameOptions = IFrameOptions<Profile> &
6
+ ProfileOptions & {
7
+ address: string;
8
+ username: string;
9
+ indexerUrl: string;
10
+ rpcUrl: string;
11
+ };
12
+
13
+ export class ProfileIFrame extends IFrame<Profile> {
14
+ constructor({
15
+ profileUrl,
16
+ address,
17
+ username,
18
+ indexerUrl,
19
+ rpcUrl,
20
+ tokens,
21
+ ...iframeOptions
22
+ }: ProfileIFrameOptions) {
23
+ const _url = new URL(profileUrl ?? PROFILE_URL);
24
+ _url.searchParams.set("address", encodeURIComponent(address));
25
+ _url.searchParams.set("username", encodeURIComponent(username));
26
+ _url.searchParams.set("indexerUrl", encodeURIComponent(indexerUrl));
27
+ _url.searchParams.set("rpcUrl", encodeURIComponent(rpcUrl));
28
+
29
+ if (tokens?.erc20) {
30
+ _url.searchParams.set(
31
+ "erc20",
32
+ encodeURIComponent(JSON.stringify(tokens.erc20)),
33
+ );
34
+ }
35
+
36
+ super({
37
+ ...iframeOptions,
38
+ id: "controller-profile",
39
+ url: _url,
40
+ });
41
+ }
42
+ }