@cartridge/controller 0.10.6 → 0.11.1
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 +17 -17
- package/.turbo/turbo-build.log +18 -18
- package/dist/controller.d.ts +16 -3
- package/dist/iframe/base.d.ts +2 -2
- package/dist/iframe/keychain.d.ts +3 -1
- package/dist/index.js +849 -701
- package/dist/index.js.map +1 -1
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.cjs.map +1 -1
- package/dist/node/index.d.cts +2 -1
- package/dist/node/index.d.ts +2 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/{provider-BgBI_LQl.js → provider-GfOahKeA.js} +121 -91
- package/dist/provider-GfOahKeA.js.map +1 -0
- package/dist/session/provider.d.ts +3 -1
- package/dist/session.js +102 -93
- package/dist/session.js.map +1 -1
- package/dist/stats.html +1 -1
- package/dist/types.d.ts +15 -23
- package/dist/url-validator.d.ts +14 -0
- package/dist/utils.d.ts +2 -1
- package/dist/wallets/types.d.ts +6 -1
- package/package.json +3 -3
- package/src/__tests__/connectDefaultChainId.test.ts +85 -0
- package/src/controller.ts +207 -21
- package/src/iframe/base.ts +52 -7
- package/src/iframe/keychain.ts +32 -7
- package/src/session/account.ts +3 -3
- package/src/session/provider.ts +16 -1
- package/src/types.ts +33 -35
- package/src/url-validator.ts +70 -0
- package/src/utils.ts +11 -3
- package/src/wallets/ethereum-base.ts +6 -1
- package/src/wallets/types.ts +18 -7
- package/vite.config.js +2 -12
- package/dist/provider-BgBI_LQl.js.map +0 -1
package/dist/types.d.ts
CHANGED
|
@@ -13,8 +13,12 @@ export type KeychainSession = {
|
|
|
13
13
|
privateKey: string;
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
|
-
export
|
|
17
|
-
export type
|
|
16
|
+
export declare const EMBEDDED_WALLETS: readonly ["google", "webauthn", "discord", "walletconnect", "password"];
|
|
17
|
+
export type EmbeddedWallet = (typeof EMBEDDED_WALLETS)[number];
|
|
18
|
+
export declare const ALL_AUTH_OPTIONS: readonly ["google", "webauthn", "discord", "walletconnect", "password", "metamask", "rabby", "argent", "braavos", "phantom", "base"];
|
|
19
|
+
export type AuthOption = (typeof ALL_AUTH_OPTIONS)[number];
|
|
20
|
+
export declare const IMPLEMENTED_AUTH_OPTIONS: ("metamask" | "rabby" | "google" | "webauthn" | "discord" | "walletconnect" | "password")[];
|
|
21
|
+
export type AuthOptions = (typeof IMPLEMENTED_AUTH_OPTIONS)[number][];
|
|
18
22
|
export declare enum ResponseCodes {
|
|
19
23
|
SUCCESS = "SUCCESS",
|
|
20
24
|
NOT_CONNECTED = "NOT_CONNECTED",
|
|
@@ -75,7 +79,7 @@ type CartridgeID = string;
|
|
|
75
79
|
export type ControllerAccounts = Record<ContractAddress, CartridgeID>;
|
|
76
80
|
export interface Keychain {
|
|
77
81
|
probe(rpcUrl: string): Promise<ProbeReply | ConnectError>;
|
|
78
|
-
connect(
|
|
82
|
+
connect(signupOptions?: AuthOptions): Promise<ConnectReply | ConnectError>;
|
|
79
83
|
disconnect(): void;
|
|
80
84
|
reset(): void;
|
|
81
85
|
revoke(origin: string): void;
|
|
@@ -92,8 +96,9 @@ export interface Keychain {
|
|
|
92
96
|
openPurchaseCredits(): void;
|
|
93
97
|
openExecute(calls: Call[]): Promise<void>;
|
|
94
98
|
switchChain(rpcUrl: string): Promise<void>;
|
|
95
|
-
openStarterPack(
|
|
99
|
+
openStarterPack(starterpackId: string | number): Promise<void>;
|
|
96
100
|
navigate(path: string): Promise<void>;
|
|
101
|
+
hasStorageAccess(): Promise<boolean>;
|
|
97
102
|
externalDetectWallets(): Promise<ExternalWallet[]>;
|
|
98
103
|
externalConnectWallet(type: ExternalWalletType, address?: string): Promise<ExternalWalletResponse>;
|
|
99
104
|
externalSignMessage(type: ExternalWalletType, message: string): Promise<ExternalWalletResponse>;
|
|
@@ -132,6 +137,8 @@ export type KeychainOptions = IFrameOptions & {
|
|
|
132
137
|
url?: string;
|
|
133
138
|
/** The origin of keychain */
|
|
134
139
|
origin?: string;
|
|
140
|
+
/** The RPC URL to use (derived from defaultChainId) */
|
|
141
|
+
rpcUrl?: string;
|
|
135
142
|
/** Propagate transaction errors back to caller instead of showing modal */
|
|
136
143
|
propagateSessionErrors?: boolean;
|
|
137
144
|
/** The fee source to use for execute from outside */
|
|
@@ -154,23 +161,8 @@ export type Token = "eth" | "strk" | "lords" | "usdc" | "usdt";
|
|
|
154
161
|
export type Tokens = {
|
|
155
162
|
erc20?: Token[];
|
|
156
163
|
};
|
|
157
|
-
export
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
export interface StarterPackItem {
|
|
162
|
-
type: StarterPackItemType;
|
|
163
|
-
name: string;
|
|
164
|
-
description: string;
|
|
165
|
-
iconURL?: string;
|
|
166
|
-
amount?: number;
|
|
167
|
-
price?: bigint;
|
|
168
|
-
call?: Call[];
|
|
169
|
-
}
|
|
170
|
-
export interface StarterPack {
|
|
171
|
-
name: string;
|
|
172
|
-
description: string;
|
|
173
|
-
iconURL?: string;
|
|
174
|
-
items: StarterPackItem[];
|
|
175
|
-
}
|
|
164
|
+
export type OpenOptions = {
|
|
165
|
+
/** The URL to redirect to after authentication (defaults to current page) */
|
|
166
|
+
redirectUrl?: string;
|
|
167
|
+
};
|
|
176
168
|
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates a redirect URL to prevent XSS and open redirect attacks.
|
|
3
|
+
*
|
|
4
|
+
* This validator is designed for the standalone auth flow where we want to
|
|
5
|
+
* support redirecting to external game domains (e.g., lootsurvivor.io)
|
|
6
|
+
* after authentication, while blocking dangerous attack vectors.
|
|
7
|
+
*
|
|
8
|
+
* @param redirectUrl - The URL to validate (from redirectUrl option)
|
|
9
|
+
* @returns Object with isValid boolean and optional error message
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateRedirectUrl(redirectUrl: string): {
|
|
12
|
+
isValid: boolean;
|
|
13
|
+
error?: string;
|
|
14
|
+
};
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Call } from 'starknet';
|
|
2
1
|
import { Policy } from '@cartridge/controller-wasm/controller';
|
|
3
2
|
import { Policies, SessionPolicies } from '@cartridge/presets';
|
|
4
3
|
import { ChainId } from '@starknet-io/types-js';
|
|
4
|
+
import { Call } from 'starknet';
|
|
5
5
|
import { ParsedSessionPolicies } from './policies';
|
|
6
6
|
export declare function normalizeCalls(calls: Call | Call[]): {
|
|
7
7
|
entrypoint: string;
|
|
@@ -13,3 +13,4 @@ export declare function toWasmPolicies(policies: ParsedSessionPolicies): Policy[
|
|
|
13
13
|
export declare function toArray<T>(val: T | T[]): T[];
|
|
14
14
|
export declare function humanizeString(str: string): string;
|
|
15
15
|
export declare function parseChainId(url: URL): ChainId;
|
|
16
|
+
export declare function isMobile(): boolean;
|
package/dist/wallets/types.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export
|
|
1
|
+
export declare const AUTH_EXTERNAL_WALLETS: readonly ["metamask", "rabby"];
|
|
2
|
+
export type AuthExternalWallet = (typeof AUTH_EXTERNAL_WALLETS)[number];
|
|
3
|
+
export declare const EXTRA_EXTERNAL_WALLETS: readonly ["argent", "braavos", "phantom", "base"];
|
|
4
|
+
export type ExtraExternalWallet = (typeof EXTRA_EXTERNAL_WALLETS)[number];
|
|
5
|
+
export declare const EXTERNAL_WALLETS: readonly ["metamask", "rabby", "argent", "braavos", "phantom", "base"];
|
|
6
|
+
export type ExternalWalletType = (typeof EXTERNAL_WALLETS)[number];
|
|
2
7
|
export type ExternalPlatform = "starknet" | "ethereum" | "solana" | "base" | "arbitrum" | "optimism";
|
|
3
8
|
export interface ExternalWallet {
|
|
4
9
|
type: ExternalWalletType;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartridge/controller",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Cartridge Controller",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@cartridge/controller-wasm": "
|
|
24
|
+
"@cartridge/controller-wasm": "0.3.16",
|
|
25
25
|
"@cartridge/penpal": "^6.2.4",
|
|
26
26
|
"micro-sol-signer": "^0.5.0",
|
|
27
27
|
"bs58": "^6.0.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"vite-plugin-node-polyfills": "^0.23.0",
|
|
51
51
|
"vite-plugin-top-level-await": "^1.4.4",
|
|
52
52
|
"vite-plugin-wasm": "^3.4.1",
|
|
53
|
-
"@cartridge/tsconfig": "0.
|
|
53
|
+
"@cartridge/tsconfig": "0.11.1"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
56
|
"build:deps": "pnpm build",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { constants } from "starknet";
|
|
2
|
+
import ControllerProvider from "../controller";
|
|
3
|
+
|
|
4
|
+
// Mock the KeychainIFrame
|
|
5
|
+
jest.mock("../iframe/keychain", () => ({
|
|
6
|
+
KeychainIFrame: jest.fn().mockImplementation(() => ({
|
|
7
|
+
open: jest.fn(),
|
|
8
|
+
close: jest.fn(),
|
|
9
|
+
})),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("ControllerProvider connect with defaultChainId", () => {
|
|
13
|
+
let originalConsoleError: any;
|
|
14
|
+
let originalConsoleWarn: any;
|
|
15
|
+
let originalConsoleLog: any;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
// Mock console methods to suppress expected errors/warnings
|
|
19
|
+
originalConsoleError = console.error;
|
|
20
|
+
originalConsoleWarn = console.warn;
|
|
21
|
+
originalConsoleLog = console.log;
|
|
22
|
+
console.error = jest.fn();
|
|
23
|
+
console.warn = jest.fn();
|
|
24
|
+
console.log = jest.fn();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
console.error = originalConsoleError;
|
|
29
|
+
console.warn = originalConsoleWarn;
|
|
30
|
+
console.log = originalConsoleLog;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("should use defaultChainId when connecting with Sepolia", async () => {
|
|
34
|
+
const controller = new ControllerProvider({
|
|
35
|
+
defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// The controller should be initialized with Sepolia RPC
|
|
39
|
+
expect(controller.rpcUrl()).toBe(
|
|
40
|
+
"https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("should use defaultChainId when connecting with Mainnet", async () => {
|
|
45
|
+
const controller = new ControllerProvider({
|
|
46
|
+
defaultChainId: constants.StarknetChainId.SN_MAIN,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// The controller should be initialized with Mainnet RPC
|
|
50
|
+
expect(controller.rpcUrl()).toBe(
|
|
51
|
+
"https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_9",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("should prioritize custom chains over default chains based on defaultChainId", async () => {
|
|
56
|
+
const customChains = [
|
|
57
|
+
{ rpcUrl: "https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const controller = new ControllerProvider({
|
|
61
|
+
chains: customChains,
|
|
62
|
+
defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Should use the Sepolia RPC since defaultChainId is set to Sepolia
|
|
66
|
+
expect(controller.rpcUrl()).toBe(
|
|
67
|
+
"https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("should use defaultChainId with custom chain configuration", async () => {
|
|
72
|
+
const controller = new ControllerProvider({
|
|
73
|
+
chains: [
|
|
74
|
+
{ rpcUrl: "https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9" },
|
|
75
|
+
{ rpcUrl: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_9" },
|
|
76
|
+
],
|
|
77
|
+
defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Should respect the defaultChainId and use Sepolia
|
|
81
|
+
expect(controller.rpcUrl()).toBe(
|
|
82
|
+
"https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/controller.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { constants, shortString, WalletAccount } from "starknet";
|
|
10
10
|
import { version } from "../package.json";
|
|
11
11
|
import ControllerAccount from "./account";
|
|
12
|
+
import { KEYCHAIN_URL } from "./constants";
|
|
12
13
|
import { NotReadyToConnect } from "./errors";
|
|
13
14
|
import { KeychainIFrame } from "./iframe";
|
|
14
15
|
import BaseProvider from "./provider";
|
|
@@ -22,16 +23,18 @@ import {
|
|
|
22
23
|
ProbeReply,
|
|
23
24
|
ProfileContextTypeVariant,
|
|
24
25
|
ResponseCodes,
|
|
25
|
-
|
|
26
|
+
OpenOptions,
|
|
26
27
|
} from "./types";
|
|
28
|
+
import { validateRedirectUrl } from "./url-validator";
|
|
27
29
|
import { parseChainId } from "./utils";
|
|
28
30
|
|
|
29
31
|
export default class ControllerProvider extends BaseProvider {
|
|
30
32
|
private keychain?: AsyncMethodReturns<Keychain>;
|
|
31
33
|
private options: ControllerOptions;
|
|
32
|
-
private iframes
|
|
34
|
+
private iframes?: IFrames;
|
|
33
35
|
private selectedChain: ChainId;
|
|
34
36
|
private chains: Map<ChainId, Chain>;
|
|
37
|
+
private referral: { ref?: string; refGroup?: string };
|
|
35
38
|
|
|
36
39
|
isReady(): boolean {
|
|
37
40
|
return !!this.keychain;
|
|
@@ -54,14 +57,89 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
54
57
|
|
|
55
58
|
this.selectedChain = defaultChainId;
|
|
56
59
|
this.chains = new Map<ChainId, Chain>();
|
|
60
|
+
|
|
61
|
+
// Auto-extract referral parameters from URL
|
|
62
|
+
// This allows games to pass referrals via their own URL: game.com/?ref=alice&ref_group=campaign1
|
|
63
|
+
const urlParams =
|
|
64
|
+
typeof window !== "undefined"
|
|
65
|
+
? new URLSearchParams(window.location.search)
|
|
66
|
+
: null;
|
|
67
|
+
this.referral = {
|
|
68
|
+
ref: urlParams?.get("ref") ?? undefined,
|
|
69
|
+
refGroup: urlParams?.get("ref_group") ?? undefined,
|
|
70
|
+
};
|
|
71
|
+
|
|
57
72
|
this.options = { ...options, chains, defaultChainId };
|
|
58
73
|
|
|
74
|
+
// Handle automatic redirect to keychain for standalone flow
|
|
75
|
+
// When controller_redirect is present, automatically redirect to keychain
|
|
76
|
+
// This establishes first-party storage access for the keychain
|
|
77
|
+
// IMPORTANT: Check this BEFORE cleaning up URL parameters
|
|
78
|
+
if (typeof window !== "undefined") {
|
|
79
|
+
// Check if controller_redirect flag is present (any value or just the key)
|
|
80
|
+
const hasControllerRedirect = urlParams?.has("controller_redirect");
|
|
81
|
+
if (hasControllerRedirect) {
|
|
82
|
+
// Use configured keychain URL (not user-provided)
|
|
83
|
+
const keychainUrl = new URL(options.url || KEYCHAIN_URL);
|
|
84
|
+
|
|
85
|
+
// Build redirect URL preserving all query params and hash except controller_redirect
|
|
86
|
+
// This matches the behavior of open() method which uses window.location.href
|
|
87
|
+
const currentUrl = new URL(window.location.href);
|
|
88
|
+
currentUrl.searchParams.delete("controller_redirect");
|
|
89
|
+
const redirectUrl = currentUrl.toString();
|
|
90
|
+
|
|
91
|
+
keychainUrl.searchParams.set("redirect_url", redirectUrl);
|
|
92
|
+
|
|
93
|
+
// Preserve the preset if it was configured in options
|
|
94
|
+
if (options.preset) {
|
|
95
|
+
keychainUrl.searchParams.set("preset", options.preset);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Redirect to keychain
|
|
99
|
+
window.location.href = keychainUrl.toString();
|
|
100
|
+
return; // Stop further initialization
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Auto-detect and set lastUsedConnector from URL parameter
|
|
105
|
+
// This is set by the keychain after redirect flow completion
|
|
106
|
+
if (typeof window !== "undefined" && typeof localStorage !== "undefined") {
|
|
107
|
+
const lastUsedConnector = urlParams?.get("lastUsedConnector");
|
|
108
|
+
if (lastUsedConnector) {
|
|
109
|
+
localStorage.setItem("lastUsedConnector", lastUsedConnector);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Clean up the URL by removing controller flow parameters
|
|
113
|
+
if (urlParams && window.history?.replaceState) {
|
|
114
|
+
let needsCleanup = false;
|
|
115
|
+
|
|
116
|
+
if (lastUsedConnector) {
|
|
117
|
+
urlParams.delete("lastUsedConnector");
|
|
118
|
+
needsCleanup = true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Also remove controller_redirect if present (shouldn't be after redirect, but just in case)
|
|
122
|
+
if (urlParams.has("controller_redirect")) {
|
|
123
|
+
urlParams.delete("controller_redirect");
|
|
124
|
+
needsCleanup = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (needsCleanup) {
|
|
128
|
+
const newUrl =
|
|
129
|
+
window.location.pathname +
|
|
130
|
+
(urlParams.toString() ? "?" + urlParams.toString() : "") +
|
|
131
|
+
window.location.hash;
|
|
132
|
+
window.history.replaceState({}, "", newUrl);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.initializeChains(chains);
|
|
138
|
+
|
|
59
139
|
this.iframes = {
|
|
60
140
|
keychain: options.lazyload ? undefined : this.createKeychainIframe(),
|
|
61
141
|
};
|
|
62
142
|
|
|
63
|
-
this.initializeChains(chains);
|
|
64
|
-
|
|
65
143
|
if (typeof window !== "undefined") {
|
|
66
144
|
(window as any).starknet_controller = this;
|
|
67
145
|
}
|
|
@@ -105,6 +183,10 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
105
183
|
}
|
|
106
184
|
|
|
107
185
|
async probe(): Promise<WalletAccount | undefined> {
|
|
186
|
+
if (!this.iframes) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
108
190
|
try {
|
|
109
191
|
// Ensure iframe is created if using lazy loading
|
|
110
192
|
if (!this.iframes.keychain) {
|
|
@@ -139,6 +221,10 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
139
221
|
}
|
|
140
222
|
|
|
141
223
|
async connect(): Promise<WalletAccount | undefined> {
|
|
224
|
+
if (!this.iframes) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
142
228
|
if (this.account) {
|
|
143
229
|
return this.account;
|
|
144
230
|
}
|
|
@@ -165,19 +251,7 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
165
251
|
this.iframes.keychain.open();
|
|
166
252
|
|
|
167
253
|
try {
|
|
168
|
-
let response = await this.keychain.connect(
|
|
169
|
-
// Policy precedence logic:
|
|
170
|
-
// 1. If shouldOverridePresetPolicies is true and policies are provided, use policies
|
|
171
|
-
// 2. Otherwise, if preset is defined, use empty object (let preset take precedence)
|
|
172
|
-
// 3. Otherwise, use provided policies or empty object
|
|
173
|
-
this.options.shouldOverridePresetPolicies && this.options.policies
|
|
174
|
-
? this.options.policies
|
|
175
|
-
: this.options.preset
|
|
176
|
-
? {}
|
|
177
|
-
: this.options.policies || {},
|
|
178
|
-
this.rpcUrl(),
|
|
179
|
-
this.options.signupOptions,
|
|
180
|
-
);
|
|
254
|
+
let response = await this.keychain.connect(this.options.signupOptions);
|
|
181
255
|
if (response.code !== ResponseCodes.SUCCESS) {
|
|
182
256
|
throw new Error(response.message);
|
|
183
257
|
}
|
|
@@ -201,6 +275,10 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
201
275
|
}
|
|
202
276
|
|
|
203
277
|
async switchStarknetChain(chainId: string): Promise<boolean> {
|
|
278
|
+
if (!this.iframes) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
204
282
|
if (!this.keychain || !this.iframes.keychain) {
|
|
205
283
|
console.error(new NotReadyToConnect().message);
|
|
206
284
|
return false;
|
|
@@ -243,6 +321,10 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
243
321
|
}
|
|
244
322
|
|
|
245
323
|
async openProfile(tab: ProfileContextTypeVariant = "inventory") {
|
|
324
|
+
if (!this.iframes) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
246
328
|
// Profile functionality is now integrated into keychain
|
|
247
329
|
// Navigate keychain iframe to profile page
|
|
248
330
|
if (!this.keychain || !this.iframes.keychain) {
|
|
@@ -267,6 +349,10 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
267
349
|
}
|
|
268
350
|
|
|
269
351
|
async openProfileTo(to: string) {
|
|
352
|
+
if (!this.iframes) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
270
356
|
// Profile functionality is now integrated into keychain
|
|
271
357
|
if (!this.keychain || !this.iframes.keychain) {
|
|
272
358
|
console.error(new NotReadyToConnect().message);
|
|
@@ -289,6 +375,10 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
289
375
|
}
|
|
290
376
|
|
|
291
377
|
async openProfileAt(at: string) {
|
|
378
|
+
if (!this.iframes) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
292
382
|
// Profile functionality is now integrated into keychain
|
|
293
383
|
if (!this.keychain || !this.iframes.keychain) {
|
|
294
384
|
console.error(new NotReadyToConnect().message);
|
|
@@ -304,6 +394,10 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
304
394
|
}
|
|
305
395
|
|
|
306
396
|
openSettings() {
|
|
397
|
+
if (!this.iframes) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
307
401
|
if (!this.keychain || !this.iframes.keychain) {
|
|
308
402
|
console.error(new NotReadyToConnect().message);
|
|
309
403
|
return;
|
|
@@ -344,27 +438,38 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
344
438
|
}
|
|
345
439
|
|
|
346
440
|
openPurchaseCredits() {
|
|
441
|
+
if (!this.iframes) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
347
445
|
if (!this.keychain || !this.iframes.keychain) {
|
|
348
446
|
console.error(new NotReadyToConnect().message);
|
|
349
447
|
return;
|
|
350
448
|
}
|
|
351
449
|
this.keychain.navigate("/purchase/credits").then(() => {
|
|
352
|
-
this.iframes
|
|
450
|
+
this.iframes!.keychain?.open();
|
|
353
451
|
});
|
|
354
452
|
}
|
|
355
453
|
|
|
356
|
-
async openStarterPack(
|
|
454
|
+
async openStarterPack(starterpackId: string | number): Promise<void> {
|
|
455
|
+
if (!this.iframes) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
357
459
|
if (!this.keychain || !this.iframes.keychain) {
|
|
358
460
|
console.error(new NotReadyToConnect().message);
|
|
359
461
|
return;
|
|
360
462
|
}
|
|
361
463
|
|
|
362
|
-
|
|
363
|
-
await this.keychain.openStarterPack(options);
|
|
464
|
+
await this.keychain.openStarterPack(starterpackId);
|
|
364
465
|
this.iframes.keychain?.open();
|
|
365
466
|
}
|
|
366
467
|
|
|
367
468
|
async openExecute(calls: any, chainId?: string) {
|
|
469
|
+
if (!this.iframes) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
368
473
|
if (!this.keychain || !this.iframes.keychain) {
|
|
369
474
|
console.error(new NotReadyToConnect().message);
|
|
370
475
|
return;
|
|
@@ -404,6 +509,84 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
404
509
|
return await this.keychain.delegateAccount();
|
|
405
510
|
}
|
|
406
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Opens the keychain in standalone mode (first-party context) for authentication.
|
|
514
|
+
* This establishes first-party storage, enabling seamless iframe access across all games.
|
|
515
|
+
* @param options - Configuration for redirect after authentication
|
|
516
|
+
*/
|
|
517
|
+
open(options: OpenOptions = {}) {
|
|
518
|
+
if (typeof window === "undefined") {
|
|
519
|
+
console.error("open can only be called in browser context");
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const keychainUrl = new URL(this.options.url || KEYCHAIN_URL);
|
|
524
|
+
|
|
525
|
+
// Add redirect target (defaults to current page)
|
|
526
|
+
const redirectUrl = options.redirectUrl || window.location.href;
|
|
527
|
+
|
|
528
|
+
// Validate redirect URL to prevent XSS and open redirect attacks
|
|
529
|
+
const validation = validateRedirectUrl(redirectUrl);
|
|
530
|
+
if (!validation.isValid) {
|
|
531
|
+
console.error(
|
|
532
|
+
`Invalid redirect URL: ${validation.error}`,
|
|
533
|
+
`URL: ${redirectUrl}`,
|
|
534
|
+
);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
keychainUrl.searchParams.set("redirect_url", redirectUrl);
|
|
539
|
+
|
|
540
|
+
// Add preset if provided
|
|
541
|
+
if (this.options.preset) {
|
|
542
|
+
keychainUrl.searchParams.set("preset", this.options.preset);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Add controller configuration parameters
|
|
546
|
+
if (this.options.slot) {
|
|
547
|
+
keychainUrl.searchParams.set("ps", this.options.slot);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (this.options.namespace) {
|
|
551
|
+
keychainUrl.searchParams.set("ns", this.options.namespace);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (this.options.tokens?.erc20) {
|
|
555
|
+
keychainUrl.searchParams.set(
|
|
556
|
+
"erc20",
|
|
557
|
+
this.options.tokens.erc20.toString(),
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (this.rpcUrl()) {
|
|
562
|
+
keychainUrl.searchParams.set("rpc_url", this.rpcUrl());
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Navigate to standalone keychain
|
|
566
|
+
window.location.href = keychainUrl.toString();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Checks if the keychain iframe has first-party storage access.
|
|
571
|
+
* Returns true if the user has previously authenticated via standalone mode.
|
|
572
|
+
* @returns Promise<boolean> indicating if storage access is available
|
|
573
|
+
*/
|
|
574
|
+
async hasFirstPartyAccess(): Promise<boolean> {
|
|
575
|
+
if (!this.keychain) {
|
|
576
|
+
console.error(new NotReadyToConnect().message);
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
// Ask the keychain iframe if it has storage access
|
|
582
|
+
const hasAccess = await this.keychain.hasStorageAccess();
|
|
583
|
+
return hasAccess;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
console.error("Error checking storage access:", error);
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
407
590
|
private initializeChains(chains: Chain[]) {
|
|
408
591
|
for (const chain of chains) {
|
|
409
592
|
try {
|
|
@@ -442,11 +625,14 @@ export default class ControllerProvider extends BaseProvider {
|
|
|
442
625
|
private createKeychainIframe(): KeychainIFrame {
|
|
443
626
|
return new KeychainIFrame({
|
|
444
627
|
...this.options,
|
|
628
|
+
rpcUrl: this.rpcUrl(),
|
|
445
629
|
onClose: this.keychain?.reset,
|
|
446
630
|
onConnect: (keychain) => {
|
|
447
631
|
this.keychain = keychain;
|
|
448
632
|
},
|
|
449
633
|
version: version,
|
|
634
|
+
ref: this.referral.ref,
|
|
635
|
+
refGroup: this.referral.refGroup,
|
|
450
636
|
});
|
|
451
637
|
}
|
|
452
638
|
|
package/src/iframe/base.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AsyncMethodReturns, connectToChild } from "@cartridge/penpal";
|
|
2
|
-
import {
|
|
2
|
+
import { Modal } from "../types";
|
|
3
3
|
|
|
4
4
|
export type IFrameOptions<CallSender> = Omit<
|
|
5
5
|
ConstructorParameters<typeof IFrame>[0],
|
|
@@ -20,11 +20,10 @@ export class IFrame<CallSender extends {}> implements Modal {
|
|
|
20
20
|
constructor({
|
|
21
21
|
id,
|
|
22
22
|
url,
|
|
23
|
-
preset,
|
|
24
23
|
onClose,
|
|
25
24
|
onConnect,
|
|
26
25
|
methods = {},
|
|
27
|
-
}:
|
|
26
|
+
}: {
|
|
28
27
|
id: string;
|
|
29
28
|
url: URL;
|
|
30
29
|
onClose?: () => void;
|
|
@@ -35,12 +34,17 @@ export class IFrame<CallSender extends {}> implements Modal {
|
|
|
35
34
|
return;
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
if (preset) {
|
|
39
|
-
url.searchParams.set("preset", preset);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
37
|
this.url = url;
|
|
43
38
|
|
|
39
|
+
const docHead = document.head;
|
|
40
|
+
|
|
41
|
+
const meta = document.createElement("meta");
|
|
42
|
+
meta.name = "viewport";
|
|
43
|
+
meta.id = "controller-viewport";
|
|
44
|
+
meta.content =
|
|
45
|
+
"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, interactive-widget=resizes-content";
|
|
46
|
+
docHead.appendChild(meta);
|
|
47
|
+
|
|
44
48
|
const iframe = document.createElement("iframe");
|
|
45
49
|
iframe.src = url.toString();
|
|
46
50
|
iframe.id = id;
|
|
@@ -52,6 +56,12 @@ export class IFrame<CallSender extends {}> implements Modal {
|
|
|
52
56
|
iframe.sandbox.add("allow-same-origin");
|
|
53
57
|
iframe.allow =
|
|
54
58
|
"publickey-credentials-create *; publickey-credentials-get *; clipboard-write";
|
|
59
|
+
iframe.style.scrollbarWidth = "none";
|
|
60
|
+
iframe.style.setProperty("-ms-overflow-style", "none");
|
|
61
|
+
iframe.style.setProperty("-webkit-scrollbar", "none");
|
|
62
|
+
// Enable Storage Access API for the iframe
|
|
63
|
+
// This allows the keychain iframe to request access to its first-party storage
|
|
64
|
+
// when embedded in third-party contexts (other games/apps)
|
|
55
65
|
if (!!document.hasStorageAccess) {
|
|
56
66
|
iframe.sandbox.add("allow-storage-access-by-user-activation");
|
|
57
67
|
}
|
|
@@ -71,8 +81,43 @@ export class IFrame<CallSender extends {}> implements Modal {
|
|
|
71
81
|
container.style.transition = "opacity 0.2s ease";
|
|
72
82
|
container.style.opacity = "0";
|
|
73
83
|
container.style.pointerEvents = "auto";
|
|
84
|
+
container.style.overscrollBehaviorY = "contain";
|
|
85
|
+
container.style.scrollbarWidth = "none";
|
|
86
|
+
container.style.setProperty("-ms-overflow-style", "none");
|
|
87
|
+
container.style.setProperty("-webkit-scrollbar", "none");
|
|
74
88
|
container.appendChild(iframe);
|
|
75
89
|
|
|
90
|
+
// Disables pinch to zoom
|
|
91
|
+
container.addEventListener(
|
|
92
|
+
"touchstart",
|
|
93
|
+
(e) => {
|
|
94
|
+
if (e.touches.length > 1) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{ passive: false },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
container.addEventListener(
|
|
102
|
+
"touchmove",
|
|
103
|
+
(e) => {
|
|
104
|
+
if (e.touches.length > 1) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{ passive: false },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
container.addEventListener(
|
|
112
|
+
"touchend",
|
|
113
|
+
(e) => {
|
|
114
|
+
if (e.touches.length > 1) {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
{ passive: false },
|
|
119
|
+
);
|
|
120
|
+
|
|
76
121
|
// Add click event listener to close iframe when clicking outside
|
|
77
122
|
container.addEventListener("click", (e) => {
|
|
78
123
|
if (e.target === container) {
|