@cartridge/controller 0.13.3 → 0.13.5
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.
- package/.turbo/turbo-build$colon$deps.log +40 -20
- package/.turbo/turbo-build.log +38 -18
- package/HEADLESS_MODE.md +113 -0
- package/LICENSE +16 -0
- package/dist/controller.d.ts +9 -2
- package/dist/errors.d.ts +10 -0
- package/dist/iframe/security.d.ts +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1662 -1244
- package/dist/index.js.map +1 -1
- package/dist/node/index.cjs +28 -3
- package/dist/node/index.cjs.map +1 -1
- package/dist/node/index.d.cts +11 -1
- package/dist/node/index.d.ts +11 -1
- package/dist/node/index.js +26 -4
- package/dist/node/index.js.map +1 -1
- package/dist/{provider-bC9cKItb.js → provider-NKp7_oNj.js} +134 -116
- package/dist/provider-NKp7_oNj.js.map +1 -0
- package/dist/session/provider.d.ts +1 -0
- package/dist/session.js +13 -10
- package/dist/session.js.map +1 -1
- package/dist/stats.html +1 -1
- package/dist/types.d.ts +20 -1
- package/package.json +26 -25
- package/src/__tests__/asWalletStandard.test.ts +87 -0
- package/src/__tests__/headlessConnectApproval.test.ts +97 -0
- package/src/__tests__/iframeSecurity.test.ts +84 -0
- package/src/__tests__/parseChainId.test.ts +1 -1
- package/src/controller.ts +165 -13
- package/src/errors.ts +30 -0
- package/src/iframe/base.ts +14 -3
- package/src/iframe/keychain.ts +1 -5
- package/src/iframe/security.ts +48 -0
- package/src/index.ts +1 -0
- package/src/session/provider.ts +2 -0
- package/src/types.ts +30 -1
- package/src/utils.ts +2 -2
- package/dist/provider-bC9cKItb.js.map +0 -1
package/dist/types.d.ts
CHANGED
|
@@ -79,7 +79,7 @@ type CartridgeID = string;
|
|
|
79
79
|
export type ControllerAccounts = Record<ContractAddress, CartridgeID>;
|
|
80
80
|
export interface Keychain {
|
|
81
81
|
probe(rpcUrl: string): Promise<ProbeReply | ConnectError>;
|
|
82
|
-
connect(
|
|
82
|
+
connect(options?: ConnectOptions): Promise<ConnectReply | ConnectError>;
|
|
83
83
|
disconnect(): void;
|
|
84
84
|
reset(): void;
|
|
85
85
|
revoke(origin: string): void;
|
|
@@ -172,4 +172,23 @@ export type StarterpackOptions = {
|
|
|
172
172
|
/** Callback fired after the Play button closes the starterpack modal */
|
|
173
173
|
onPurchaseComplete?: () => void;
|
|
174
174
|
};
|
|
175
|
+
export interface ConnectOptions {
|
|
176
|
+
/** Signup options (shown in UI when not headless) */
|
|
177
|
+
signupOptions?: AuthOptions;
|
|
178
|
+
/** Headless mode username (when combined with signer) */
|
|
179
|
+
username?: string;
|
|
180
|
+
/** Headless mode signer option (auth method) */
|
|
181
|
+
signer?: AuthOption;
|
|
182
|
+
/** Required when signer is "password" */
|
|
183
|
+
password?: string;
|
|
184
|
+
}
|
|
185
|
+
export type HeadlessConnectOptions = Required<Pick<ConnectOptions, "username" | "signer">> & Pick<ConnectOptions, "password">;
|
|
186
|
+
export type HeadlessConnectReply = {
|
|
187
|
+
code: ResponseCodes.SUCCESS;
|
|
188
|
+
address: string;
|
|
189
|
+
} | {
|
|
190
|
+
code: ResponseCodes.USER_INTERACTION_REQUIRED;
|
|
191
|
+
requestId: string;
|
|
192
|
+
message?: string;
|
|
193
|
+
} | ConnectError;
|
|
175
194
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartridge/controller",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
4
4
|
"description": "Cartridge Controller",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -10,17 +10,6 @@
|
|
|
10
10
|
"module": "dist/index.js",
|
|
11
11
|
"types": "dist/index.d.ts",
|
|
12
12
|
"type": "module",
|
|
13
|
-
"scripts": {
|
|
14
|
-
"build:deps": "pnpm build",
|
|
15
|
-
"dev": "vite build --watch",
|
|
16
|
-
"build:browser": "vite build",
|
|
17
|
-
"build:node": "tsup --config tsup.node.config.ts",
|
|
18
|
-
"build": "pnpm build:browser && pnpm build:node",
|
|
19
|
-
"format": "prettier --write \"src/**/*.ts\"",
|
|
20
|
-
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
21
|
-
"test": "jest",
|
|
22
|
-
"version": "pnpm pkg get version"
|
|
23
|
-
},
|
|
24
13
|
"exports": {
|
|
25
14
|
".": {
|
|
26
15
|
"types": "./dist/index.d.ts",
|
|
@@ -37,11 +26,12 @@
|
|
|
37
26
|
}
|
|
38
27
|
},
|
|
39
28
|
"dependencies": {
|
|
40
|
-
"@cartridge/controller-wasm": "
|
|
41
|
-
"@cartridge/penpal": "
|
|
29
|
+
"@cartridge/controller-wasm": "0.9.3",
|
|
30
|
+
"@cartridge/penpal": "^6.2.4",
|
|
42
31
|
"micro-sol-signer": "^0.5.0",
|
|
43
32
|
"bs58": "^6.0.0",
|
|
44
33
|
"ethers": "^6.13.5",
|
|
34
|
+
"@starknet-io/get-starknet-wallet-standard": "5.0.0-beta.0",
|
|
45
35
|
"@starknet-io/types-js": "0.9.1",
|
|
46
36
|
"@telegram-apps/sdk": "^2.4.0",
|
|
47
37
|
"@turnkey/sdk-browser": "^4.0.0",
|
|
@@ -52,20 +42,31 @@
|
|
|
52
42
|
"@walletconnect/ethereum-provider": "^2.20.0"
|
|
53
43
|
},
|
|
54
44
|
"devDependencies": {
|
|
55
|
-
"@
|
|
56
|
-
"@types/
|
|
57
|
-
"@types/
|
|
58
|
-
"@types/node": "catalog:",
|
|
45
|
+
"@types/jest": "^29.5.14",
|
|
46
|
+
"@types/mocha": "^10.0.10",
|
|
47
|
+
"@types/node": "^18.0.6",
|
|
59
48
|
"jest": "^29.7.0",
|
|
60
|
-
"prettier": "
|
|
49
|
+
"prettier": "^3.4.2",
|
|
61
50
|
"rollup-plugin-visualizer": "^5.12.0",
|
|
62
51
|
"ts-jest": "^29.2.5",
|
|
63
|
-
"tsup": "
|
|
64
|
-
"typescript": "
|
|
65
|
-
"vite": "
|
|
52
|
+
"tsup": "^8.0.1",
|
|
53
|
+
"typescript": "^5.7.3",
|
|
54
|
+
"vite": "^6.0.0",
|
|
66
55
|
"vite-plugin-dts": "^4.5.3",
|
|
67
56
|
"vite-plugin-node-polyfills": "^0.23.0",
|
|
68
|
-
"vite-plugin-top-level-await": "
|
|
69
|
-
"vite-plugin-wasm": "
|
|
57
|
+
"vite-plugin-top-level-await": "^1.4.4",
|
|
58
|
+
"vite-plugin-wasm": "^3.4.1",
|
|
59
|
+
"@cartridge/tsconfig": "0.13.5"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build:deps": "pnpm build",
|
|
63
|
+
"dev": "vite build --watch",
|
|
64
|
+
"build:browser": "vite build",
|
|
65
|
+
"build:node": "tsup --config tsup.node.config.ts",
|
|
66
|
+
"build": "pnpm build:browser && pnpm build:node",
|
|
67
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
68
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
69
|
+
"test": "jest",
|
|
70
|
+
"version": "pnpm pkg get version"
|
|
70
71
|
}
|
|
71
|
-
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import ControllerProvider from "../controller";
|
|
2
|
+
|
|
3
|
+
// Mock StarknetInjectedWallet
|
|
4
|
+
const mockInnerDisconnect = jest.fn().mockResolvedValue(undefined);
|
|
5
|
+
const mockFeatures = {
|
|
6
|
+
"standard:connect": { version: "1.0.0", connect: jest.fn() },
|
|
7
|
+
"standard:disconnect": {
|
|
8
|
+
version: "1.0.0",
|
|
9
|
+
disconnect: mockInnerDisconnect,
|
|
10
|
+
},
|
|
11
|
+
"standard:events": { version: "1.0.0", on: jest.fn() },
|
|
12
|
+
"starknet:walletApi": { version: "1.0.0", request: jest.fn() },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
jest.mock("@starknet-io/get-starknet-wallet-standard", () => ({
|
|
16
|
+
StarknetInjectedWallet: jest.fn().mockImplementation(() => ({
|
|
17
|
+
version: "1.0.0",
|
|
18
|
+
name: "Controller",
|
|
19
|
+
icon: "data:image/svg+xml,<svg/>",
|
|
20
|
+
chains: ["starknet:mainnet"],
|
|
21
|
+
accounts: [],
|
|
22
|
+
features: mockFeatures,
|
|
23
|
+
})),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe("asWalletStandard", () => {
|
|
27
|
+
let controller: ControllerProvider;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
jest.clearAllMocks();
|
|
31
|
+
console.warn = jest.fn();
|
|
32
|
+
console.error = jest.fn();
|
|
33
|
+
controller = new ControllerProvider({});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("should delegate properties from inner wallet", () => {
|
|
37
|
+
const wallet = controller.asWalletStandard();
|
|
38
|
+
|
|
39
|
+
expect(wallet.version).toBe("1.0.0");
|
|
40
|
+
expect(wallet.name).toBe("Controller");
|
|
41
|
+
expect(wallet.chains).toEqual(["starknet:mainnet"]);
|
|
42
|
+
expect(wallet.accounts).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("should forward non-disconnect features from inner wallet", () => {
|
|
46
|
+
const wallet = controller.asWalletStandard();
|
|
47
|
+
|
|
48
|
+
expect(wallet.features["standard:connect"]).toBe(
|
|
49
|
+
mockFeatures["standard:connect"],
|
|
50
|
+
);
|
|
51
|
+
expect(wallet.features["standard:events"]).toBe(
|
|
52
|
+
mockFeatures["standard:events"],
|
|
53
|
+
);
|
|
54
|
+
expect(wallet.features["starknet:walletApi"]).toBe(
|
|
55
|
+
mockFeatures["starknet:walletApi"],
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("should call controller.disconnect when wallet standard disconnect is called", async () => {
|
|
60
|
+
const wallet = controller.asWalletStandard();
|
|
61
|
+
const controllerDisconnect = jest
|
|
62
|
+
.spyOn(controller, "disconnect")
|
|
63
|
+
.mockResolvedValue(undefined);
|
|
64
|
+
|
|
65
|
+
await wallet.features["standard:disconnect"].disconnect();
|
|
66
|
+
|
|
67
|
+
expect(mockInnerDisconnect).toHaveBeenCalled();
|
|
68
|
+
expect(controllerDisconnect).toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("should call inner disconnect before controller disconnect", async () => {
|
|
72
|
+
const callOrder: string[] = [];
|
|
73
|
+
|
|
74
|
+
mockInnerDisconnect.mockImplementation(async () => {
|
|
75
|
+
callOrder.push("inner");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
jest.spyOn(controller, "disconnect").mockImplementation(async () => {
|
|
79
|
+
callOrder.push("controller");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const wallet = controller.asWalletStandard();
|
|
83
|
+
await wallet.features["standard:disconnect"].disconnect();
|
|
84
|
+
|
|
85
|
+
expect(callOrder).toEqual(["inner", "controller"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ResponseCodes } from "../types";
|
|
2
|
+
|
|
3
|
+
let iframeOpen: jest.Mock | undefined;
|
|
4
|
+
|
|
5
|
+
// Mock the KeychainIFrame so we can manually trigger onSessionCreated.
|
|
6
|
+
jest.mock("../iframe/keychain", () => ({
|
|
7
|
+
KeychainIFrame: jest.fn().mockImplementation((_opts: any) => {
|
|
8
|
+
iframeOpen = jest.fn();
|
|
9
|
+
return {
|
|
10
|
+
open: iframeOpen,
|
|
11
|
+
close: jest.fn(),
|
|
12
|
+
};
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Keep ControllerAccount lightweight for this test.
|
|
17
|
+
jest.mock("../account", () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: jest
|
|
20
|
+
.fn()
|
|
21
|
+
.mockImplementation(
|
|
22
|
+
(
|
|
23
|
+
_provider: unknown,
|
|
24
|
+
_rpcUrl: string,
|
|
25
|
+
address: string,
|
|
26
|
+
_keychain: unknown,
|
|
27
|
+
_options: unknown,
|
|
28
|
+
) => ({
|
|
29
|
+
address,
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import ControllerProvider from "../controller";
|
|
35
|
+
|
|
36
|
+
describe("headless connect requiring approval", () => {
|
|
37
|
+
test("does not open UI and resolves connect() only after keychain.connect()", async () => {
|
|
38
|
+
const controller = new ControllerProvider();
|
|
39
|
+
|
|
40
|
+
let resolveConnect: ((value: any) => void) | undefined;
|
|
41
|
+
const connectPromise = new Promise((resolve) => {
|
|
42
|
+
resolveConnect = resolve;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const keychain = {
|
|
46
|
+
connect: jest.fn().mockReturnValue(connectPromise),
|
|
47
|
+
disconnect: jest.fn(),
|
|
48
|
+
reset: jest.fn(),
|
|
49
|
+
} as any;
|
|
50
|
+
|
|
51
|
+
// Avoid waiting for Penpal connection in this unit test.
|
|
52
|
+
(controller as any).keychain = keychain;
|
|
53
|
+
(controller as any).waitForKeychain = () => Promise.resolve();
|
|
54
|
+
|
|
55
|
+
const accountsChanged = jest.fn();
|
|
56
|
+
controller.on("accountsChanged", accountsChanged as any);
|
|
57
|
+
|
|
58
|
+
const controllerConnectPromise = controller.connect({
|
|
59
|
+
username: "alice",
|
|
60
|
+
signer: "webauthn",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let resolved = false;
|
|
64
|
+
void controllerConnectPromise.then(() => {
|
|
65
|
+
resolved = true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Flush microtasks for:
|
|
69
|
+
// 1) await waitForKeychain()
|
|
70
|
+
// 2) await keychain.connect(...)
|
|
71
|
+
await Promise.resolve();
|
|
72
|
+
await Promise.resolve();
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
|
|
75
|
+
expect(keychain.connect).toHaveBeenCalledWith({
|
|
76
|
+
username: "alice",
|
|
77
|
+
signer: "webauthn",
|
|
78
|
+
password: undefined,
|
|
79
|
+
});
|
|
80
|
+
expect(iframeOpen).not.toHaveBeenCalled();
|
|
81
|
+
expect(resolved).toBe(false);
|
|
82
|
+
|
|
83
|
+
resolveConnect?.({
|
|
84
|
+
code: ResponseCodes.SUCCESS,
|
|
85
|
+
address: "0xabc",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const account = await controllerConnectPromise;
|
|
89
|
+
expect(account?.address).toBe("0xabc");
|
|
90
|
+
expect(accountsChanged).toHaveBeenCalledWith(["0xabc"]);
|
|
91
|
+
|
|
92
|
+
// Subsequent connect() should short-circuit (no second approval flow).
|
|
93
|
+
const account2 = await controller.connect();
|
|
94
|
+
expect(account2?.address).toBe("0xabc");
|
|
95
|
+
expect(keychain.connect).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildIframeAllowList,
|
|
3
|
+
isLocalhostHostname,
|
|
4
|
+
validateKeychainIframeUrl,
|
|
5
|
+
} from "../iframe/security";
|
|
6
|
+
|
|
7
|
+
describe("iframe security", () => {
|
|
8
|
+
describe("isLocalhostHostname", () => {
|
|
9
|
+
it("returns true for localhost hosts", () => {
|
|
10
|
+
expect(isLocalhostHostname("localhost")).toBe(true);
|
|
11
|
+
expect(isLocalhostHostname("app.localhost")).toBe(true);
|
|
12
|
+
expect(isLocalhostHostname("127.0.0.1")).toBe(true);
|
|
13
|
+
expect(isLocalhostHostname("[::1]")).toBe(true);
|
|
14
|
+
expect(isLocalhostHostname("::1")).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns false for non-local hosts", () => {
|
|
18
|
+
expect(isLocalhostHostname("example.com")).toBe(false);
|
|
19
|
+
expect(isLocalhostHostname("localhost.example.com")).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("validateKeychainIframeUrl", () => {
|
|
24
|
+
it("allows https URLs", () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
validateKeychainIframeUrl(new URL("https://x.cartridge.gg")),
|
|
27
|
+
).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("allows localhost http URLs for development", () => {
|
|
31
|
+
expect(() =>
|
|
32
|
+
validateKeychainIframeUrl(new URL("http://localhost:3001")),
|
|
33
|
+
).not.toThrow();
|
|
34
|
+
expect(() =>
|
|
35
|
+
validateKeychainIframeUrl(new URL("http://127.0.0.1:3001")),
|
|
36
|
+
).not.toThrow();
|
|
37
|
+
expect(() =>
|
|
38
|
+
validateKeychainIframeUrl(new URL("http://[::1]:3001")),
|
|
39
|
+
).not.toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects insecure remote http URLs", () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
validateKeychainIframeUrl(new URL("http://evil.example")),
|
|
45
|
+
).toThrow(
|
|
46
|
+
"Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects non-http(s) protocols", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
validateKeychainIframeUrl(new URL("javascript:alert(1)")),
|
|
53
|
+
).toThrow(
|
|
54
|
+
"Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(() =>
|
|
58
|
+
validateKeychainIframeUrl(new URL("data:text/html,<h1>xss</h1>")),
|
|
59
|
+
).toThrow(
|
|
60
|
+
"Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects credentialed URLs", () => {
|
|
65
|
+
expect(() =>
|
|
66
|
+
validateKeychainIframeUrl(new URL("https://user:pass@x.cartridge.gg")),
|
|
67
|
+
).toThrow("Invalid keychain iframe URL: credentials are not allowed");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("buildIframeAllowList", () => {
|
|
72
|
+
it("does not include local-network-access for remote URLs", () => {
|
|
73
|
+
const allowList = buildIframeAllowList(new URL("https://x.cartridge.gg"));
|
|
74
|
+
expect(allowList).toContain("publickey-credentials-create *");
|
|
75
|
+
expect(allowList).toContain("payment *");
|
|
76
|
+
expect(allowList).not.toContain("local-network-access *");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("includes local-network-access for localhost development URLs", () => {
|
|
80
|
+
const allowList = buildIframeAllowList(new URL("http://localhost:3001"));
|
|
81
|
+
expect(allowList).toContain("local-network-access *");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -44,7 +44,7 @@ describe("parseChainId", () => {
|
|
|
44
44
|
|
|
45
45
|
describe("Non-Cartridge hosts", () => {
|
|
46
46
|
test("returns placeholder chainId in Node", () => {
|
|
47
|
-
expect(parseChainId(new URL("http://dl:
|
|
47
|
+
expect(parseChainId(new URL("http://dl:1234"))).toBe(
|
|
48
48
|
shortString.encodeShortString("LOCALHOST"),
|
|
49
49
|
);
|
|
50
50
|
});
|
package/src/controller.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AsyncMethodReturns } from "@cartridge/penpal";
|
|
2
2
|
|
|
3
3
|
import { Policy } from "@cartridge/presets";
|
|
4
|
+
import { StarknetInjectedWallet } from "@starknet-io/get-starknet-wallet-standard";
|
|
5
|
+
import type { WalletWithStarknetFeatures } from "@starknet-io/get-starknet-wallet-standard/features";
|
|
4
6
|
import {
|
|
5
7
|
AddInvokeTransactionResult,
|
|
6
8
|
AddStarknetChainParameters,
|
|
@@ -10,7 +12,7 @@ import { constants, shortString, WalletAccount } from "starknet";
|
|
|
10
12
|
import { version } from "../package.json";
|
|
11
13
|
import ControllerAccount from "./account";
|
|
12
14
|
import { KEYCHAIN_URL } from "./constants";
|
|
13
|
-
import { NotReadyToConnect } from "./errors";
|
|
15
|
+
import { HeadlessAuthenticationError, NotReadyToConnect } from "./errors";
|
|
14
16
|
import { KeychainIFrame } from "./iframe";
|
|
15
17
|
import BaseProvider from "./provider";
|
|
16
18
|
import {
|
|
@@ -18,6 +20,7 @@ import {
|
|
|
18
20
|
Chain,
|
|
19
21
|
ConnectError,
|
|
20
22
|
ConnectReply,
|
|
23
|
+
ConnectOptions,
|
|
21
24
|
ControllerOptions,
|
|
22
25
|
IFrames,
|
|
23
26
|
Keychain,
|
|
@@ -226,8 +229,18 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
226
229
|
}
|
|
227
230
|
|
|
228
231
|
async connect(
|
|
229
|
-
|
|
232
|
+
options?: AuthOptions | ConnectOptions,
|
|
230
233
|
): Promise<WalletAccount | undefined> {
|
|
234
|
+
const connectOptions = Array.isArray(options) ? undefined : options;
|
|
235
|
+
const headless =
|
|
236
|
+
connectOptions?.username && connectOptions?.signer
|
|
237
|
+
? {
|
|
238
|
+
username: connectOptions.username,
|
|
239
|
+
signer: connectOptions.signer,
|
|
240
|
+
password: connectOptions.password,
|
|
241
|
+
}
|
|
242
|
+
: undefined;
|
|
243
|
+
|
|
231
244
|
if (!this.iframes) {
|
|
232
245
|
return;
|
|
233
246
|
}
|
|
@@ -239,21 +252,73 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
239
252
|
// Ensure iframe is created if using lazy loading
|
|
240
253
|
if (!this.iframes.keychain) {
|
|
241
254
|
this.iframes.keychain = this.createKeychainIframe();
|
|
242
|
-
// Wait for the keychain to be ready
|
|
243
|
-
await this.waitForKeychain();
|
|
244
255
|
}
|
|
245
256
|
|
|
257
|
+
// Always wait for the keychain connection to be established
|
|
258
|
+
await this.waitForKeychain();
|
|
259
|
+
|
|
246
260
|
if (!this.keychain || !this.iframes.keychain) {
|
|
247
261
|
console.error(new NotReadyToConnect().message);
|
|
248
262
|
return;
|
|
249
263
|
}
|
|
250
264
|
|
|
251
|
-
this.iframes.keychain.open();
|
|
252
|
-
|
|
253
265
|
try {
|
|
266
|
+
if (headless) {
|
|
267
|
+
// Headless auth should not open the UI until the keychain determines
|
|
268
|
+
// user interaction is required (e.g. session approval).
|
|
269
|
+
const response = await this.keychain.connect({
|
|
270
|
+
username: headless.username,
|
|
271
|
+
signer: headless.signer,
|
|
272
|
+
password: headless.password,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (response.code !== ResponseCodes.SUCCESS) {
|
|
276
|
+
throw new HeadlessAuthenticationError(
|
|
277
|
+
"message" in response && response.message
|
|
278
|
+
? response.message
|
|
279
|
+
: "Headless authentication failed",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Keychain will call onSessionCreated (awaitable) during headless connect,
|
|
284
|
+
// which probes and updates this.account. Keep a fallback for older keychains.
|
|
285
|
+
if (this.account) {
|
|
286
|
+
return this.account;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const address =
|
|
290
|
+
"address" in response && response.address ? response.address : null;
|
|
291
|
+
if (!address) {
|
|
292
|
+
throw new HeadlessAuthenticationError(
|
|
293
|
+
"Headless authentication failed",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.account = new ControllerAccount(
|
|
298
|
+
this,
|
|
299
|
+
this.rpcUrl(),
|
|
300
|
+
address,
|
|
301
|
+
this.keychain,
|
|
302
|
+
this.options,
|
|
303
|
+
this.iframes.keychain,
|
|
304
|
+
);
|
|
305
|
+
this.emitAccountsChanged([address]);
|
|
306
|
+
return this.account;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Only open modal if NOT headless
|
|
310
|
+
this.iframes.keychain.open();
|
|
311
|
+
|
|
254
312
|
// Use connect() parameter if provided, otherwise fall back to constructor options
|
|
255
|
-
const effectiveOptions =
|
|
256
|
-
|
|
313
|
+
const effectiveOptions = Array.isArray(options)
|
|
314
|
+
? options
|
|
315
|
+
: (connectOptions?.signupOptions ?? this.options.signupOptions);
|
|
316
|
+
|
|
317
|
+
// Pass options to keychain
|
|
318
|
+
let response = await this.keychain.connect({
|
|
319
|
+
signupOptions: effectiveOptions,
|
|
320
|
+
});
|
|
321
|
+
|
|
257
322
|
if (response.code !== ResponseCodes.SUCCESS) {
|
|
258
323
|
throw new Error(response.message);
|
|
259
324
|
}
|
|
@@ -270,9 +335,25 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
270
335
|
|
|
271
336
|
return this.account;
|
|
272
337
|
} catch (e) {
|
|
338
|
+
if (headless) {
|
|
339
|
+
if (e instanceof HeadlessAuthenticationError) {
|
|
340
|
+
throw e;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const message =
|
|
344
|
+
e instanceof Error
|
|
345
|
+
? e.message
|
|
346
|
+
: typeof e === "object" && e && "message" in e
|
|
347
|
+
? String((e as any).message)
|
|
348
|
+
: "Headless authentication failed";
|
|
349
|
+
throw new HeadlessAuthenticationError(message);
|
|
350
|
+
}
|
|
273
351
|
console.log(e);
|
|
274
352
|
} finally {
|
|
275
|
-
|
|
353
|
+
// Only close modal if it was opened (not headless)
|
|
354
|
+
if (!headless) {
|
|
355
|
+
this.iframes.keychain.close();
|
|
356
|
+
}
|
|
276
357
|
}
|
|
277
358
|
}
|
|
278
359
|
|
|
@@ -306,12 +387,22 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
306
387
|
}
|
|
307
388
|
|
|
308
389
|
async disconnect() {
|
|
390
|
+
this.account = undefined;
|
|
391
|
+
this.emitAccountsChanged([]);
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
if (typeof localStorage !== "undefined") {
|
|
395
|
+
localStorage.removeItem("lastUsedConnector");
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
// Ignore environments where localStorage is unavailable.
|
|
399
|
+
}
|
|
400
|
+
|
|
309
401
|
if (!this.keychain) {
|
|
310
402
|
console.error(new NotReadyToConnect().message);
|
|
311
403
|
return;
|
|
312
404
|
}
|
|
313
405
|
|
|
314
|
-
this.account = undefined;
|
|
315
406
|
return this.keychain.disconnect();
|
|
316
407
|
}
|
|
317
408
|
|
|
@@ -401,6 +492,13 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
401
492
|
this.keychain.openSettings();
|
|
402
493
|
}
|
|
403
494
|
|
|
495
|
+
async close() {
|
|
496
|
+
if (!this.iframes || !this.iframes.keychain) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
this.iframes.keychain.close();
|
|
500
|
+
}
|
|
501
|
+
|
|
404
502
|
revoke(origin: string, _policy: Policy[]) {
|
|
405
503
|
if (!this.keychain) {
|
|
406
504
|
console.error(new NotReadyToConnect().message);
|
|
@@ -514,6 +612,54 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
514
612
|
return await this.keychain.delegateAccount();
|
|
515
613
|
}
|
|
516
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Returns a wallet standard interface for the controller.
|
|
617
|
+
* This allows using the controller with libraries that expect the wallet standard interface.
|
|
618
|
+
*/
|
|
619
|
+
asWalletStandard(): WalletWithStarknetFeatures {
|
|
620
|
+
if (typeof window !== "undefined") {
|
|
621
|
+
console.warn(
|
|
622
|
+
`Casting Controller to WalletWithStarknetFeatures is an experimental feature. ` +
|
|
623
|
+
`Please report any issues at https://github.com/cartridge-gg/controller/issues`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const controller = this;
|
|
628
|
+
const inner = new StarknetInjectedWallet(controller);
|
|
629
|
+
|
|
630
|
+
// Override disconnect to also disconnect controller
|
|
631
|
+
const disconnect = {
|
|
632
|
+
"standard:disconnect": {
|
|
633
|
+
version: "1.0.0" as const,
|
|
634
|
+
disconnect: async () => {
|
|
635
|
+
await inner.features["standard:disconnect"].disconnect();
|
|
636
|
+
await controller.disconnect();
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
get version() {
|
|
643
|
+
return inner.version;
|
|
644
|
+
},
|
|
645
|
+
get name() {
|
|
646
|
+
return inner.name;
|
|
647
|
+
},
|
|
648
|
+
get icon() {
|
|
649
|
+
return inner.icon;
|
|
650
|
+
},
|
|
651
|
+
get chains() {
|
|
652
|
+
return inner.chains;
|
|
653
|
+
},
|
|
654
|
+
get accounts() {
|
|
655
|
+
return inner.accounts;
|
|
656
|
+
},
|
|
657
|
+
get features() {
|
|
658
|
+
return { ...inner.features, ...disconnect };
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
517
663
|
/**
|
|
518
664
|
* Opens the keychain in standalone mode (first-party context) for authentication.
|
|
519
665
|
* This establishes first-party storage, enabling seamless iframe access across all games.
|
|
@@ -622,7 +768,9 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
622
768
|
const iframe = new KeychainIFrame({
|
|
623
769
|
...this.options,
|
|
624
770
|
rpcUrl: this.rpcUrl(),
|
|
625
|
-
onClose:
|
|
771
|
+
onClose: () => {
|
|
772
|
+
this.keychain?.reset?.();
|
|
773
|
+
},
|
|
626
774
|
onConnect: (keychain) => {
|
|
627
775
|
this.keychain = keychain;
|
|
628
776
|
},
|
|
@@ -633,8 +781,12 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
633
781
|
encryptedBlob: encryptedBlob ?? undefined,
|
|
634
782
|
username: username,
|
|
635
783
|
onSessionCreated: async () => {
|
|
636
|
-
|
|
637
|
-
await this.probe();
|
|
784
|
+
const previousAddress = this.account?.address;
|
|
785
|
+
const account = await this.probe();
|
|
786
|
+
|
|
787
|
+
if (account?.address && account.address !== previousAddress) {
|
|
788
|
+
this.emitAccountsChanged([account.address]);
|
|
789
|
+
}
|
|
638
790
|
},
|
|
639
791
|
});
|
|
640
792
|
|