Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion examples/next/src/components/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,15 @@ export function Profile() {
<h2>Open Starterpack</h2>
<div className="flex flex-col gap-1">
<div className="flex flex-wrap gap-1">
<Button onClick={() => ctrlConnector.controller.openStarterPack(0)}>
<Button
onClick={() =>
ctrlConnector.controller.openStarterPack(0, {
onPurchaseComplete: () => {
console.log("Starterpack play callback fired.");
},
})
}
>
Nums
</Button>
</div>
Expand Down
5 changes: 5 additions & 0 deletions examples/next/src/components/Starterpack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export const Starterpack = () => {
onClick={() => {
controllerConnector.controller.openStarterPack(
purchaseOnchainSpId,
{
onPurchaseComplete: () => {
console.log("Starterpack play callback fired.");
},
},
);
}}
>
Expand Down
9 changes: 8 additions & 1 deletion packages/controller/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,14 @@ export default class ControllerProvider extends BaseProvider {
return;
}

await this.keychain.openStarterPack(id, options);
const { onPurchaseComplete, ...starterpackOptions } = options ?? {};
this.iframes.keychain.setOnStarterpackPlay(onPurchaseComplete);
const sanitizedOptions =
Object.keys(starterpackOptions).length > 0
? (starterpackOptions as Omit<StarterpackOptions, "onPurchaseComplete">)
: undefined;

await this.keychain.openStarterPack(id, sanitizedOptions);
this.iframes.keychain?.open();
}

Expand Down
32 changes: 32 additions & 0 deletions packages/controller/src/iframe/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ type KeychainIframeOptions = IFrameOptions<Keychain> &
needsSessionCreation?: boolean;
username?: string;
onSessionCreated?: () => void;
onStarterpackPlay?: () => void;
encryptedBlob?: string;
};

const STARTERPACK_PLAY_CALLBACK_DELAY_MS = 200;

export class KeychainIFrame extends IFrame<Keychain> {
private walletBridge: WalletBridge;
private onStarterpackPlay?: () => void;

constructor({
url,
Expand All @@ -32,11 +36,13 @@ export class KeychainIFrame extends IFrame<Keychain> {
needsSessionCreation,
username,
onSessionCreated,
onStarterpackPlay,
encryptedBlob,
propagateSessionErrors,
errorDisplayMode,
...iframeOptions
}: KeychainIframeOptions) {
let onStarterpackPlayHandler: (() => Promise<void>) | undefined;
const _url = new URL(url ?? KEYCHAIN_URL);
const walletBridge = new WalletBridge();

Expand Down Expand Up @@ -118,10 +124,32 @@ export class KeychainIFrame extends IFrame<Keychain> {
onSessionCreated();
}
},
onStarterpackPlay: (_origin: string) => async () => {
if (onStarterpackPlayHandler) {
await onStarterpackPlayHandler();
}
},
},
});

this.walletBridge = walletBridge;
this.onStarterpackPlay = onStarterpackPlay;
onStarterpackPlayHandler = async () => {
this.close();
const callback = this.onStarterpackPlay;
this.onStarterpackPlay = undefined;
if (!callback) {
return;
}
await new Promise((resolve) =>
setTimeout(resolve, STARTERPACK_PLAY_CALLBACK_DELAY_MS),
);
try {
callback();
} catch (error) {
console.error("Failed to run starterpack play callback:", error);
}
};

// Expose the wallet bridge instance globally for WASM interop
if (typeof window !== "undefined") {
Expand All @@ -132,4 +160,8 @@ export class KeychainIFrame extends IFrame<Keychain> {
getWalletBridge(): WalletBridge {
return this.walletBridge;
}

setOnStarterpackPlay(callback?: () => void) {
this.onStarterpackPlay = callback;
}
}
2 changes: 2 additions & 0 deletions packages/controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,4 +273,6 @@ export type OpenOptions = {
export type StarterpackOptions = {
/** The preimage to use */
preimage?: string;
/** Callback fired after the Play button closes the starterpack modal */
onPurchaseComplete?: () => void;
};
11 changes: 4 additions & 7 deletions packages/keychain/src/components/purchasenew/pending/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { Receiving } from "../receiving";
import { ConfirmingTransaction } from "./confirming-transaction";
import { Item } from "@/context";
import { useConnection } from "@/hooks/connection";
import { useNavigation } from "@/context";
import { useEffect, useState } from "react";
import { TransactionFinalityStatus } from "starknet";
import { retryWithBackoff } from "@/utils/retry";
import { getExplorer } from "@/hooks/starterpack/layerswap";
import { useStarterpackPlayHandler } from "@/hooks/starterpack";

export interface TransactionPendingBaseProps {
/** Header title (e.g., "Purchasing Village Kit") */
Expand Down Expand Up @@ -44,8 +44,8 @@ export function TransactionPendingBase({
buttonText,
quantityText,
}: TransactionPendingBaseProps) {
const { isMainnet, controller, closeModal } = useConnection();
const { navigateToRoot } = useNavigation();
const { isMainnet, controller } = useConnection();
const handlePlay = useStarterpackPlayHandler();
const [isPending, setIsPending] = useState(true);

useEffect(() => {
Expand Down Expand Up @@ -93,10 +93,7 @@ export function TransactionPendingBase({
className="w-full"
variant="primary"
disabled={isPending}
onClick={() => {
closeModal?.();
navigateToRoot();
}}
onClick={handlePlay}
>
{buttonText}
</Button>
Expand Down
12 changes: 4 additions & 8 deletions packages/keychain/src/components/purchasenew/pending/bridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import {
import { Explorer, getExplorer } from "@/hooks/starterpack/layerswap";
import { ExternalWallet, humanizeString } from "@cartridge/controller";
import { useEffect, useState, useRef } from "react";
import { useNavigation } from "@/context";
import { useConnection } from "@/hooks/connection";
import { retryWithBackoff } from "@/utils/retry";
import { ControllerErrorAlert } from "@/components/ErrorAlert";
import { TransactionFinalityStatus } from "starknet";
import { CoinbaseTransactionStatus } from "@/utils/api";
import { useStarterpackPlayHandler } from "@/hooks/starterpack";

interface TransitionStepProps {
isVisible: boolean;
Expand Down Expand Up @@ -73,11 +73,10 @@ export function BridgePending({
selectedPlatform: selectedPlatformProp,
waitForDeposit: waitForDepositProp,
}: BridgePendingProps) {
const { navigateToRoot } = useNavigation();
const onchainContext = useOnchainPurchaseContext();
const { transactionHash: currentTxHash } = useStarterpackContext();
const { externalWaitForTransaction, controller, isMainnet, closeModal } =
useConnection();
const { externalWaitForTransaction, controller, isMainnet } = useConnection();
const handlePlay = useStarterpackPlayHandler();

// Use props if provided (for stories), otherwise use context
const selectedPlatform =
Expand Down Expand Up @@ -282,10 +281,7 @@ export function BridgePending({
className="w-full"
variant="primary"
disabled={!purchaseCompleted}
onClick={() => {
closeModal?.();
navigateToRoot();
}}
onClick={handlePlay}
>
Play
</Button>
Expand Down
15 changes: 4 additions & 11 deletions packages/keychain/src/components/purchasenew/success.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
import { Receiving } from "./receiving";
import { useConnection } from "@/hooks/connection";
import {
useNavigation,
useStarterpackContext,
useOnchainPurchaseContext,
Item,
Expand All @@ -17,6 +16,7 @@ import { useMemo } from "react";
import { ConfirmingTransaction } from "./pending";
import { getExplorer } from "@/hooks/starterpack/layerswap";
import { StarterpackType } from "@/context";
import { useStarterpackPlayHandler } from "@/hooks/starterpack";

export function Success() {
const { starterpackDetails, transactionHash, claimItems } =
Expand Down Expand Up @@ -50,8 +50,8 @@ export function PurchaseSuccessInner({
transactionHash?: string;
}) {
const { quantity } = useOnchainPurchaseContext();
const { closeModal, isMainnet } = useConnection();
const { navigateToRoot } = useNavigation();
const { isMainnet } = useConnection();
const handlePlay = useStarterpackPlayHandler();
const quantityText = quantity > 1 ? `(${quantity})` : "";

return (
Expand All @@ -78,14 +78,7 @@ export function PurchaseSuccessInner({
isLoading={false}
/>
)}
<Button
onClick={() => {
closeModal?.();
navigateToRoot();
}}
>
Play
</Button>
<Button onClick={handlePlay}>Play</Button>
</LayoutFooter>
</>
);
Expand Down
24 changes: 15 additions & 9 deletions packages/keychain/src/hooks/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ export type ParentMethods = AsyncMethodReturns<{

// Session creation callback (for standalone auth flow)
onSessionCreated?: () => Promise<void>;

// Starterpack play callback (for purchase completion flow)
onStarterpackPlay?: () => Promise<void>;
}>;

/**
Expand Down Expand Up @@ -426,11 +429,6 @@ export function useConnectionValue() {
return;
}

if (!configData.origin) {
setVerified(false);
return;
}

const allowedOrigins = toArray(configData.origin as string | string[]);

// In standalone mode (not iframe), verify preset if redirect_url matches preset whitelist
Expand All @@ -443,8 +441,10 @@ export function useConnectionValue() {
const redirectUrlObj = new URL(redirectUrl);
const redirectOrigin = redirectUrlObj.origin;

// Always consider localhost as verified for development
const isLocalhost = redirectOrigin.includes("localhost");
// Always consider localhost and capacitor as verified for development
const isLocalhost =
redirectOrigin.includes("localhost") ||
redirectOrigin.startsWith("capacitor://");
const isOriginAllowed = isOriginVerified(
redirectOrigin,
allowedOrigins,
Expand All @@ -463,10 +463,16 @@ export function useConnectionValue() {
return;
}

if (!configData.origin) {
setVerified(false);
return;
}

// Embedded mode: verify against parent origin
// Always consider localhost as verified for development (not 127.0.0.1)
// Always consider localhost and capacitor as verified for development (not 127.0.0.1)
if (origin) {
const isLocalhost = origin.includes("localhost");
const isLocalhost =
origin.includes("localhost") || origin.startsWith("capacitor://");
const isOriginAllowed = isOriginVerified(origin, allowedOrigins);
const finalVerified = isLocalhost || isOriginAllowed;
setVerified(finalVerified);
Expand Down
2 changes: 2 additions & 0 deletions packages/keychain/src/hooks/starterpack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,5 @@ export type {
CoinbaseTransactionResult,
CoinbaseQuoteResult,
} from "./coinbase";

export { useStarterpackPlayHandler } from "./play";
57 changes: 57 additions & 0 deletions packages/keychain/src/hooks/starterpack/play.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { renderHook, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { useStarterpackPlayHandler } from "./play";

const mocks = vi.hoisted(() => ({
closeModal: vi.fn(),
navigateToRoot: vi.fn(),
parent: undefined as undefined | { onStarterpackPlay?: () => Promise<void> },
}));

vi.mock("@/hooks/connection", () => ({
useConnection: () => ({
closeModal: mocks.closeModal,
parent: mocks.parent,
}),
}));

vi.mock("@/context", () => ({
useNavigation: () => ({
navigateToRoot: mocks.navigateToRoot,
}),
}));

describe("useStarterpackPlayHandler", () => {
beforeEach(() => {
mocks.closeModal.mockClear();
mocks.navigateToRoot.mockClear();
mocks.parent = undefined;
});

it("closes the modal when no parent callback exists", () => {
const { result } = renderHook(() => useStarterpackPlayHandler());

result.current();

expect(mocks.closeModal).toHaveBeenCalledTimes(1);
expect(mocks.navigateToRoot).toHaveBeenCalledTimes(1);
});

it("falls back to closing the modal when the callback rejects", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mocks.parent = {
onStarterpackPlay: vi.fn().mockRejectedValue(new Error("nope")),
};
const { result } = renderHook(() => useStarterpackPlayHandler());

result.current();

await waitFor(() => {
expect(mocks.closeModal).toHaveBeenCalledTimes(1);
});

consoleError.mockRestore();
});
});
25 changes: 25 additions & 0 deletions packages/keychain/src/hooks/starterpack/play.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useCallback } from "react";
import { useConnection } from "@/hooks/connection";
import { useNavigation } from "@/context";

export function useStarterpackPlayHandler() {
const { closeModal, parent } = useConnection();
const { navigateToRoot } = useNavigation();

return useCallback(() => {
if (
parent &&
"onStarterpackPlay" in parent &&
typeof parent.onStarterpackPlay === "function"
) {
parent.onStarterpackPlay().catch((error: unknown) => {
console.error("Failed to notify parent of starterpack play:", error);
closeModal?.();
});
} else {
closeModal?.();
}

navigateToRoot();
}, [parent, closeModal, navigateToRoot]);
}
Loading