From f30c3ffef328a7f630c81905f9b2191db8fe0172 Mon Sep 17 00:00:00 2001 From: Bhiiktor Date: Thu, 24 Jul 2025 17:16:04 -0500 Subject: [PATCH 1/2] on grant change feature updated the code for the correct behavior --- .changeset/lazy-weeks-attack.md | 9 + apps/demo-app/README.md | 1 - apps/demo-app/src/app/default-ui/page.tsx | 4 + .../src/app/grant-change-demo/page.tsx | 252 ++++ apps/demo-app/src/app/layout.tsx | 71 +- apps/demo-app/src/app/page.tsx | 5 + apps/demo-app/src/app/ui-less/page.tsx | 4 + .../components/GrantChangeNotification.tsx | 113 ++ .../abstraxion-core/src/AbstraxionAuth.ts | 1186 +++++++++-------- packages/abstraxion-react-native/README.md | 115 +- .../components/AbstraxionContext/index.tsx | 344 ++--- packages/abstraxion/README.md | 85 +- .../src/components/Abstraxion/index.tsx | 2 + .../components/AbstraxionContext/index.tsx | 372 +++--- .../src/hooks/useAbstraxionAccount.ts | 12 +- packages/constants/src/index.ts | 4 +- 16 files changed, 1628 insertions(+), 951 deletions(-) create mode 100644 .changeset/lazy-weeks-attack.md create mode 100644 apps/demo-app/src/app/grant-change-demo/page.tsx create mode 100644 apps/demo-app/src/components/GrantChangeNotification.tsx diff --git a/.changeset/lazy-weeks-attack.md b/.changeset/lazy-weeks-attack.md new file mode 100644 index 00000000..412db3ec --- /dev/null +++ b/.changeset/lazy-weeks-attack.md @@ -0,0 +1,9 @@ +--- +"@burnt-labs/abstraxion-react-native": minor +"@burnt-labs/abstraxion-core": minor +"@burnt-labs/abstraxion": minor +"@burnt-labs/constants": minor +"demo-app": minor +--- + +on grant change feature, enableLogoutOnGrantChange and grantsChanged added diff --git a/apps/demo-app/README.md b/apps/demo-app/README.md index 58dc3500..79e70450 100644 --- a/apps/demo-app/README.md +++ b/apps/demo-app/README.md @@ -24,5 +24,4 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. - Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/apps/demo-app/src/app/default-ui/page.tsx b/apps/demo-app/src/app/default-ui/page.tsx index 328f4a87..23bf7d62 100644 --- a/apps/demo-app/src/app/default-ui/page.tsx +++ b/apps/demo-app/src/app/default-ui/page.tsx @@ -10,6 +10,7 @@ import { Button } from "@burnt-labs/ui"; import "@burnt-labs/ui/dist/index.css"; import "@burnt-labs/abstraxion/dist/index.css"; import Link from "next/link"; +import { GrantChangeNotification } from "../../components/GrantChangeNotification"; export default function DefaultUIPage(): JSX.Element { const { data: account } = useAbstraxionAccount(); @@ -72,6 +73,9 @@ export default function DefaultUIPage(): JSX.Element { > ← Back to examples + + {/* Grant Change Notification */} + ); } diff --git a/apps/demo-app/src/app/grant-change-demo/page.tsx b/apps/demo-app/src/app/grant-change-demo/page.tsx new file mode 100644 index 00000000..0c3d557a --- /dev/null +++ b/apps/demo-app/src/app/grant-change-demo/page.tsx @@ -0,0 +1,252 @@ +"use client"; +import { useState, useEffect } from "react"; +import { + useAbstraxionAccount, + useAbstraxionSigningClient, +} from "@burnt-labs/abstraxion"; +import { Button } from "@burnt-labs/ui"; +import "@burnt-labs/ui/dist/index.css"; +import Link from "next/link"; +import { GrantChangeNotification } from "../../components/GrantChangeNotification"; + +export default function GrantChangeDemoPage(): JSX.Element { + const { + data: account, + login, + logout, + isConnecting, + grantsChanged, + } = useAbstraxionAccount(); + + const { client } = useAbstraxionSigningClient(); + const [notifications, setNotifications] = useState([]); + + // Monitor grant changes and log them + useEffect(() => { + if (grantsChanged) { + const timestamp = new Date().toLocaleTimeString(); + setNotifications((prev) => [ + ...prev, + `${timestamp}: Grant changes detected!`, + ]); + } + }, [grantsChanged]); + + const addNotification = (message: string) => { + const timestamp = new Date().toLocaleTimeString(); + setNotifications((prev) => [...prev, `${timestamp}: ${message}`]); + }; + + const clearNotifications = () => { + setNotifications([]); + }; + + const handleLogin = async () => { + try { + addNotification("Attempting to login..."); + await login(); + addNotification("Login successful!"); + } catch (error) { + addNotification(`Login failed: ${error}`); + } + }; + + const handleLogout = () => { + addNotification("Logging out..."); + logout(); + addNotification("Logged out successfully!"); + }; + + return ( +
+
+

+ Grant Change Demo +

+

+ This page demonstrates the auto logout feature when grant changes are + detected. +

+
+ +
+ {/* Connection Status */} +
+

+ Connection Status +

+ +
+
+ Connected: + + {account.bech32Address ? "Yes" : "No"} + +
+ +
+ Grants Changed: + + {grantsChanged ? "Yes" : "No"} + +
+ +
+ Client: + + {client ? "Connected" : "Not connected"} + +
+ + {account.bech32Address && ( +
+

Address:

+

+ {account.bech32Address} +

+
+ )} +
+ +
+ + + {account.bech32Address && ( + + )} +
+
+ + {/* Feature Information */} +
+

+ Feature Information +

+ +
+
+

Auto Logout

+

+ When enabled, users are automatically logged out if grant + changes are detected at startup. +

+
+ +
+

Grant Monitoring

+

+ The system checks for grant changes when the app starts, + comparing current grants with treasury configuration. +

+
+ +
+

+ Developer Control +

+

+ Developers can access the `grantsChanged` property to implement + custom handling and user notifications. +

+
+ +
+

Configuration

+

+ Set `enableLogoutOnGrantChange: true` in your provider config to + enable automatic logout. +

+
+
+
+
+ + {/* Activity Log */} +
+
+

Activity Log

+ +
+ +
+ {notifications.length === 0 ? ( +

No activity yet...

+ ) : ( +
+ {notifications.map((notification, index) => ( +
+ {notification} +
+ ))} +
+ )} +
+
+ + {/* Code Example */} +
+

+ Implementation Example +

+ +
+
+            {`// Enable auto logout in provider config
+const config = {
+  treasury: "xion1...",
+  enableLogoutOnGrantChange: true
+};
+
+// Use in components
+const { grantsChanged, isConnected } = useAbstraxionAccount();
+
+useEffect(() => {
+  if (grantsChanged && !isConnected) {
+    // Handle grant changes with custom UX
+    showNotification("Permissions updated. Please reconnect.");
+  }
+}, [grantsChanged, isConnected]);`}
+          
+
+
+ +
+ + ← Back to examples + +
+ + {/* Grant Change Notification */} + addNotification("Reconnected via notification")} + onDismiss={() => addNotification("Notification dismissed")} + /> +
+ ); +} diff --git a/apps/demo-app/src/app/layout.tsx b/apps/demo-app/src/app/layout.tsx index 16fb4cd8..c1b9da87 100644 --- a/apps/demo-app/src/app/layout.tsx +++ b/apps/demo-app/src/app/layout.tsx @@ -7,49 +7,50 @@ const inter = Inter({ subsets: ["latin"] }); // Example XION seat contract const seatContractAddress = - "xion1z70cvc08qv5764zeg3dykcyymj5z6nu4sqr7x8vl4zjef2gyp69s9mmdka"; + "xion12724lueegeee65l5ekdq5p2wtz7euevdl0vyxv7h75ls4pt0qkasvg7tca"; const legacyConfig = { - contracts: [ - // Usually, you would have a list of different contracts here - seatContractAddress, - { - address: seatContractAddress, - amounts: [{ denom: "uxion", amount: "1000000" }], - }, - ], - stake: true, - bank: [ - { - denom: "uxion", - amount: "1000000", - }, - ], - // Optional params to activate mainnet config - // rpcUrl: "https://rpc.xion-mainnet-1.burnt.com:443", - // restUrl: "https://api.xion-mainnet-1.burnt.com:443", + contracts: [ + // Usually, you would have a list of different contracts here + seatContractAddress, + { + address: seatContractAddress, + amounts: [{ denom: "uxion", amount: "1000000" }], + }, + ], + stake: true, + bank: [ + { + denom: "uxion", + amount: "1000000", + }, + ], + // Optional params to activate mainnet config + // rpcUrl: "https://rpc.xion-mainnet-1.burnt.com:443", + // restUrl: "https://api.xion-mainnet-1.burnt.com:443", }; const treasuryConfig = { - treasury: "xion13uwmwzdes7urtjyv7mye8ty6uk0vsgdrh2a2k94tp0yxx9vv3e9qazapyu", // Example XION treasury instance for instantiating smart contracts - gasPrice: "0.001uxion", // If you feel the need to change the gasPrice when connecting to signer, set this value. Please stick to the string format seen in example - // Optional params to activate mainnet config - // rpcUrl: "https://rpc.xion-mainnet-1.burnt.com:443", - // restUrl: "https://api.xion-mainnet-1.burnt.com:443", + treasury: "xion12724lueegeee65l5ekdq5p2wtz7euevdl0vyxv7h75ls4pt0qkasvg7tca", // Example XION treasury instance for instantiating smart contracts + gasPrice: "0.001uxion", // If you feel the need to change the gasPrice when connecting to signer, set this value. Please stick to the string format seen in example + autoLogoutOnGrantChange: false, // Disable auto logout to allow custom handling + // Optional params to activate mainnet config + // rpcUrl: "https://rpc.xion-mainnet-1.burnt.com:443", + // restUrl: "https://api.xion-mainnet-1.burnt.com:443", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): JSX.Element { - return ( - - - - {children} - - - - ); + return ( + + + + {children} + + + + ); } diff --git a/apps/demo-app/src/app/page.tsx b/apps/demo-app/src/app/page.tsx index d5371578..b30ece3e 100644 --- a/apps/demo-app/src/app/page.tsx +++ b/apps/demo-app/src/app/page.tsx @@ -20,6 +20,11 @@ export default function Page(): JSX.Element { LEGACY UI EXAMPLE + + + ); diff --git a/apps/demo-app/src/app/ui-less/page.tsx b/apps/demo-app/src/app/ui-less/page.tsx index c9d1b1d4..53e8bcde 100644 --- a/apps/demo-app/src/app/ui-less/page.tsx +++ b/apps/demo-app/src/app/ui-less/page.tsx @@ -7,6 +7,7 @@ import { import { Button } from "@burnt-labs/ui"; import "@burnt-labs/ui/dist/index.css"; import Link from "next/link"; +import { GrantChangeNotification } from "../../components/GrantChangeNotification"; export default function UILessPage(): JSX.Element { const { data: account, login, logout, isConnecting } = useAbstraxionAccount(); @@ -94,6 +95,9 @@ export default function UILessPage(): JSX.Element { > ← Back to examples + + {/* Grant Change Notification */} + ); } diff --git a/apps/demo-app/src/components/GrantChangeNotification.tsx b/apps/demo-app/src/components/GrantChangeNotification.tsx new file mode 100644 index 00000000..55a7b364 --- /dev/null +++ b/apps/demo-app/src/components/GrantChangeNotification.tsx @@ -0,0 +1,113 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useAbstraxionAccount } from "@burnt-labs/abstraxion"; +import { Button } from "@burnt-labs/ui"; + +interface GrantChangeNotificationProps { + onReconnect?: () => void; + onDismiss?: () => void; +} + +export function GrantChangeNotification({ + onReconnect, + onDismiss, +}: GrantChangeNotificationProps): JSX.Element | null { + const { data: account, grantsChanged, login } = useAbstraxionAccount(); + const [showNotification, setShowNotification] = useState(false); + const [isReconnecting, setIsReconnecting] = useState(false); + + useEffect(() => { + // Show notification when grants have changed and user is not connected + if (grantsChanged && !account.bech32Address) { + setShowNotification(true); + } else { + setShowNotification(false); + } + }, [grantsChanged, account.bech32Address]); + + const handleReconnect = async () => { + try { + setIsReconnecting(true); + await login(); + onReconnect?.(); + setShowNotification(false); + } catch (error) { + console.error("Error reconnecting:", error); + } finally { + setIsReconnecting(false); + } + }; + + const handleDismiss = () => { + setShowNotification(false); + onDismiss?.(); + }; + + if (!showNotification) { + return null; + } + + return ( +
+
+
+
+ + + +
+

+ Permissions Updated +

+
+ +
+

+ The app's permissions have been updated since your last session. + You've been automatically logged out for security reasons. +

+

+ Please reconnect to continue using the app with the updated + permissions. +

+
+ +
+ + +
+ +
+

+ 💡 Developer Note: This notification appears when + the treasury contract configuration changes between sessions, + ensuring users always have the correct permissions. +

+
+
+
+ ); +} diff --git a/packages/abstraxion-core/src/AbstraxionAuth.ts b/packages/abstraxion-core/src/AbstraxionAuth.ts index 473ecb65..5450b6b1 100644 --- a/packages/abstraxion-core/src/AbstraxionAuth.ts +++ b/packages/abstraxion-core/src/AbstraxionAuth.ts @@ -2,579 +2,631 @@ import { GasPrice } from "@cosmjs/stargate"; import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate"; import { fetchConfig } from "@burnt-labs/constants"; import type { - ContractGrantDescription, - DecodedReadableAuthorization, - GrantsResponse, - SpendLimit, + ContractGrantDescription, + DecodedReadableAuthorization, + GrantsResponse, + SpendLimit, } from "@/types"; import { GranteeSignerClient } from "./GranteeSignerClient"; import { SignArbSecp256k1HdWallet } from "./SignArbSecp256k1HdWallet"; import type { RedirectStrategy, StorageStrategy } from "./types/strategyTypes"; import { - compareBankGrants, - compareChainGrantsToTreasuryGrants, - compareContractGrants, - compareStakeGrants, - decodeAuthorization, - fetchChainGrantsABCI, - getTreasuryContractConfigsByTypeUrl, - getTreasuryContractTypeUrls, + compareBankGrants, + compareChainGrantsToTreasuryGrants, + compareContractGrants, + compareStakeGrants, + decodeAuthorization, + fetchChainGrantsABCI, + getTreasuryContractConfigsByTypeUrl, + getTreasuryContractTypeUrls, } from "@/utils/grant"; export class AbstraxionAuth { - // Config - private rpcUrl?: string; - grantContracts?: ContractGrantDescription[]; - stake?: boolean; - bank?: SpendLimit[]; - callbackUrl?: string; - treasury?: string; - - // Signer - private client?: GranteeSignerClient; - private cosmwasmQueryClient?: CosmWasmClient; - - // Accounts - abstractAccount?: SignArbSecp256k1HdWallet; - - // State - private isLoginInProgress = false; - isLoggedIn = false; - authStateChangeSubscribers: ((isLoggedIn: boolean) => void)[] = []; - - /** - * Creates an instance of the AbstraxionAuth class. - */ - constructor( - private storageStrategy: StorageStrategy, - private redirectStrategy: RedirectStrategy, - ) { - // Specific to mobile flow - if (this.redirectStrategy.onRedirectComplete) { - this.redirectStrategy.onRedirectComplete(async (params) => { - if (params.granter) { - await this.setGranter(params.granter); - await this.login(); - } - }); - } - } - - /** - * Updates AbstraxionAuth instance with user config - * - * @param {string} rpc - The RPC URL used for communication with the blockchain. - * @param {ContractGrantDescription[]} [grantContracts] - Contracts for granting permissions. - * @param {boolean} [stake] - Indicates whether staking is enabled. - * @param {SpendLimit[]} [bank] - The spend limits for the user. - * @param {string} callbackUrl - preferred callback url to override default - * @param {string} treasury - treasury contract instance address - */ - configureAbstraxionInstance( - rpc: string, - grantContracts?: ContractGrantDescription[], - stake?: boolean, - bank?: SpendLimit[], - callbackUrl?: string, - treasury?: string, - ) { - this.rpcUrl = rpc; - this.grantContracts = grantContracts; - this.stake = stake; - this.bank = bank; - this.callbackUrl = callbackUrl; - this.treasury = treasury; - } - - /** - * Subscribes to changes in authentication state. - * When the authentication state changes, the provided callback function is invoked - * with the new authentication state (isLoggedIn). - * Returns an unsubscribe function that can be called to remove the subscription. - * - * @param {function} callback - A function to be invoked when the authentication state changes. - * Receives a single parameter, isLoggedIn, indicating whether the user is logged in. - * The callback should accept a boolean parameter. - * @returns {function} - A function that, when called, removes the subscription to authentication state changes. - * This function should be invoked to clean up the subscription when no longer needed. - */ - subscribeToAuthStateChange(callback: (isLoggedIn: boolean) => void) { - this.authStateChangeSubscribers.push(callback); - return () => { - const index = this.authStateChangeSubscribers.indexOf(callback); - if (index !== -1) { - this.authStateChangeSubscribers.splice(index, 1); - } - }; - } - - /** - * Triggers a change in authentication state and notifies all subscribers. - * - * @param {boolean} isLoggedIn - The new authentication state, indicating whether the user is logged in. - */ - private triggerAuthStateChange(isLoggedIn: boolean): void { - this.isLoggedIn = isLoggedIn; - this.authStateChangeSubscribers.forEach((callback) => callback(isLoggedIn)); - } - - /** - * Get the account address of the granter from persisted state. - * - * @returns {string} The account address of the granter wallet (XION Meta Account). - */ - async getGranter(): Promise { - const granterAddress = await this.storageStrategy.getItem( - "xion-authz-granter-account", - ); - if ( - !granterAddress || - granterAddress === undefined || - granterAddress === "undefined" - ) { - return ""; - } - return granterAddress; - } - - /** - * Remove persisted instance of granter account. - */ - private async removeGranterAddress(): Promise { - await this.storageStrategy.removeItem("xion-authz-granter-account"); - } - - /** - * Set a persisted instance for granter account. - * - * @param {string} address - account address of the granter wallet (XION Meta Account). - */ - private async setGranter(address: string): Promise { - await this.storageStrategy.setItem("xion-authz-granter-account", address); - } - - /** - * Get temp keypair from persisted state. - */ - async getLocalKeypair(): Promise { - const localKeypair = await this.storageStrategy.getItem( - "xion-authz-temp-account", - ); - if (!localKeypair) { - return undefined; - } - return await SignArbSecp256k1HdWallet.deserialize( - localKeypair, - "abstraxion", - ); - } - - /** - * Generate a new temp keypair and store in persisted state. - */ - async generateAndStoreTempAccount(): Promise { - const keypair = await SignArbSecp256k1HdWallet.generate(12, { - prefix: "xion", - }); - - const serializedKeypair = await keypair.serialize("abstraxion"); - await this.storageStrategy.setItem( - "xion-authz-temp-account", - serializedKeypair, - ); - - await this.removeGranterAddress(); // Prevent multiple truth issue - - return keypair; - } - - /** - * Get keypair account address. - */ - async getKeypairAddress(): Promise { - const keypair = await this.getLocalKeypair(); - if (!keypair) return ""; - const accounts = await keypair.getAccounts(); - const address = accounts[0].address; - return address; - } - - /** - * Get GranteeSignerClient for the temp keypair. - */ - async getSigner(): Promise { - try { - if (this.client) { - return this.client; - } - - if (!this.rpcUrl) { - throw new Error("Configuration not initialized"); - } - - if (!this.abstractAccount) { - throw new Error("No account found."); - } - - const granterAddress = await this.getGranter(); - - if (!granterAddress) { - throw new Error("No granter found."); - } - - const granteeAddress = await this.abstractAccount - .getAccounts() - .then((accounts: any) => { - if (accounts.length === 0) { - throw new Error("No account found."); - } - return accounts[0].address; - }); - - const directClient = await GranteeSignerClient.connectWithSigner( - this.rpcUrl, - this.abstractAccount, - { - gasPrice: GasPrice.fromString("0uxion"), - granterAddress, - granteeAddress, - treasuryAddress: this.treasury, - }, - ); - - this.client = directClient; - return directClient; - } catch (error) { - console.warn("Something went wrong getting signer: ", error); - this.client = undefined; - throw error; - } - } - - /** - * Get non-signing CosmWasmClient - * @returns {Promise} A Promise that resolves to a CosmWasmClient - * @throws {Error} If the rpcUrl is missing, or if there is a network issue. - */ - async getCosmWasmClient(): Promise { - try { - if (this.cosmwasmQueryClient) { - return this.cosmwasmQueryClient; - } - - if (!this.rpcUrl) { - throw new Error("Configuration not initialized"); - } - - const cosmwasmClient = await CosmWasmClient.connect(this.rpcUrl || ""); - - this.cosmwasmQueryClient = cosmwasmClient; - return cosmwasmClient; - } catch (error) { - console.warn("Something went wrong getting cosmwasm client: ", error); - this.cosmwasmQueryClient = undefined; - throw error; - } - } - - /** - * Get dashboard url and redirect in order to issue claim with XION meta account for local keypair. - */ - async redirectToDashboard() { - try { - if (!this.rpcUrl) { - throw new Error("AbstraxionAuth needs to be configured."); - } - const userAddress = await this.getKeypairAddress(); - const { dashboardUrl } = await fetchConfig(this.rpcUrl); - await this.configureUrlAndRedirect(dashboardUrl, userAddress); - } catch (error) { - console.warn( - "Something went wrong trying to redirect to XION dashboard: ", - error, - ); - } - } - - /** - * Configure URL and redirect page - */ - private async configureUrlAndRedirect( - dashboardUrl: string, - userAddress: string, - ): Promise { - if (typeof window !== "undefined") { - const currentUrl = this.callbackUrl || window.location.href; - const urlParams = new URLSearchParams(); - - if (this.treasury) { - urlParams.set("treasury", this.treasury); - } - - if (this.bank) { - urlParams.set("bank", JSON.stringify(this.bank)); - } - - if (this.stake) { - urlParams.set("stake", "true"); - } - - if (this.grantContracts) { - urlParams.set("contracts", JSON.stringify(this.grantContracts)); - } - - urlParams.set("grantee", userAddress); - urlParams.set("redirect_uri", currentUrl); - - const queryString = urlParams.toString(); - await this.redirectStrategy.redirect(`${dashboardUrl}?${queryString}`); - } else { - console.warn("Window not defined. Cannot redirect to dashboard"); - } - } - - /** - * Compares a GrantsResponse object to the legacy configuration stored in the instance. - * Validates the presence and attributes of grants for each authorization type. - * - * @param {GrantsResponse} grantsResponse - The grants response object containing the chain grants. - * @returns {boolean} - Returns `true` if the grants match the expected configuration; otherwise, `false`. - */ - compareGrantsToLegacyConfig(grantsResponse: GrantsResponse): boolean { - const { grants } = grantsResponse; - - return ( - compareContractGrants(grants, this.grantContracts) && - compareStakeGrants(grants, this.stake) && - compareBankGrants(grants, this.bank) - ); - } - - /** - * Compares treasury grant configurations with the grants on-chain to ensure they match. - * - * @param {GrantsResponse} grantsResponse - The grants currently existing on-chain. - * @returns {Promise} - Returns a promise that resolves to `true` if all treasury grants match chain grants; otherwise, `false`. - * @throws {Error} - Throws an error if the treasury contract is missing. - */ - async compareGrantsToTreasury( - grantsResponse: GrantsResponse, - ): Promise { - if (!this.treasury) { - throw new Error("Missing treasury"); - } - - const cosmwasmClient = - this.cosmwasmQueryClient || (await this.getCosmWasmClient()); - - const treasuryTypeUrls = await getTreasuryContractTypeUrls( - cosmwasmClient, - this.treasury, - ); - const treasuryGrantConfigs = await getTreasuryContractConfigsByTypeUrl( - cosmwasmClient, - this.treasury, - treasuryTypeUrls, - ); - - const decodedTreasuryConfigs: DecodedReadableAuthorization[] = - treasuryGrantConfigs.map((treasuryGrantConfig) => { - return decodeAuthorization( - treasuryGrantConfig.authorization.type_url, - treasuryGrantConfig.authorization.value, - ); - }); - - const decodedChainConfigs: DecodedReadableAuthorization[] = - grantsResponse.grants.map((grantResponse) => { - return decodeAuthorization( - grantResponse.authorization.typeUrl, - grantResponse.authorization.value, - ); - }); - - return compareChainGrantsToTreasuryGrants( - decodedChainConfigs, - decodedTreasuryConfigs, - ); - } - - /** - * Poll for grants issued to a grantee from a granter. - * - * @param {string} grantee - The address of the grantee. - * @param {string | null} granter - The address of the granter, or null if not available. - * @returns {Promise} A Promise that resolves to true if grants are found, otherwise false. - * @throws {Error} If the grantee or granter address is invalid, or if maximum retries are exceeded. - */ - async pollForGrants( - grantee: string, - granter: string | null, - ): Promise { - if (!this.rpcUrl) { - throw new Error("AbstraxionAuth needs to be configured."); - } - if (!grantee) { - throw new Error("No keypair address"); - } - if (!granter) { - throw new Error("No granter address"); - } - - const maxRetries = 5; - let retries = 0; - - while (retries < maxRetries) { - try { - const data = await fetchChainGrantsABCI(grantee, granter, this.rpcUrl); - - if (data.grants.length === 0) { - console.warn("No grants found."); - return false; - } - - // Check expiration for each grant - const currentTime = new Date().toISOString(); - const validGrant = data.grants.some((grant) => { - const { expiration } = grant; - return !expiration || expiration > currentTime; - }); - - let isValid: boolean; - if (this.treasury) { - isValid = await this.compareGrantsToTreasury(data); - } else { - isValid = this.compareGrantsToLegacyConfig(data); - } - - return validGrant && isValid; - } catch (error) { - console.warn("Error fetching grants: ", error); - const delay = Math.pow(2, retries) * 1000; - await new Promise((resolve) => setTimeout(resolve, delay)); - retries++; - } - } - console.error("Max retries exceeded, giving up."); - return false; - } - - /** - * Wipe persisted state and instance variables. - */ - async logout(): Promise { - await Promise.all([ - this.storageStrategy.removeItem("xion-authz-temp-account"), - this.storageStrategy.removeItem("xion-authz-granter-account"), - ]); - this.abstractAccount = undefined; - this.triggerAuthStateChange(false); - } - - /** - * Authenticates the user based on the presence of a local keypair and a granter address. - * Also checks if the grant is still valid by verifying the expiration. - * If valid, sets the abstract account and triggers authentication state change. - * If expired, clears local state and prompts reauthorization. - * - * @returns {Promise} - Resolves if authentication is successful or logs out the user otherwise. - */ - async authenticate(): Promise { - try { - const keypair = await this.getLocalKeypair(); - const granter = await this.getGranter(); - - if (!keypair || !granter) { - console.warn("Missing keypair or granter, cannot authenticate."); - return; - } - - const accounts = await keypair.getAccounts(); - const keypairAddress = accounts[0].address; - - // Check for existing grants with an expiration check - const isGrantValid = await this.pollForGrants(keypairAddress, granter); - - if (isGrantValid) { - this.abstractAccount = keypair; - this.triggerAuthStateChange(true); - } else { - throw new Error( - "Grants expired, no longer valid, or not found. Logging out.", - ); - } - } catch (error) { - console.error("Error during authentication:", error); - await this.logout(); - } - } - - /** - * Initiates the login process for the user. - * Checks if a local keypair and granter address exist, either from URL parameters or this.storageStrategy. - * If both exist, polls for grants and updates the authentication state if successful. - * If not, generates a new keypair and redirects to the dashboard for grant issuance. - * - * @returns {Promise} - A Promise that resolves once the login process is complete. - * @throws {Error} - If the login process encounters an error. - */ - async login(): Promise { - try { - if (this.isLoginInProgress) { - console.warn("Login is already in progress."); - return; - } - this.isLoginInProgress = true; - // Get local keypair and granter address from either URL param (if new) or this.storageStrategy (if existing) - const keypair = await this.getLocalKeypair(); - const storedGranter = await this.getGranter(); - const urlGranter = await this.redirectStrategy.getUrlParameter("granter"); - const granter = storedGranter || urlGranter; - - // If both exist, we can assume user is either 1. already logged in and grants have been created for the temp key, or 2. been redirected with the granter url param - // In either case, we poll for grants and make the appropriate state changes to reflect a "logged in" state - if (keypair && granter) { - const accounts = await keypair.getAccounts(); - const keypairAddress = accounts[0].address; - const pollSuccess = await this.pollForGrants(keypairAddress, granter); - if (!pollSuccess) { - throw new Error("Poll was unsuccessful. Please try again"); - } - - this.setGranter(granter); - this.abstractAccount = keypair; - this.triggerAuthStateChange(true); - - if (typeof window !== "undefined") { - const currentUrl = new URL(window.location.href); - currentUrl.searchParams.delete("granted"); - currentUrl.searchParams.delete("granter"); - history.pushState({}, "", currentUrl.href); - } - } else { - // If there isn't an existing keypair, or there isn't a granter in either this.storageStrategy or the url params, we want to start from scratch - // Generate new keypair and redirect to dashboard - await this.newKeypairFlow(); - } - return; - } catch (error) { - console.warn("Something went wrong: ", error); - throw error; - } finally { - this.isLoginInProgress = false; - } - } - - /** - * Initiates the flow to generate a new keypair and redirect to the dashboard for grant issuance. - */ - private async newKeypairFlow(): Promise { - try { - await this.generateAndStoreTempAccount(); - await this.redirectToDashboard(); - } catch (error) { - console.warn("Something went wrong: ", error); - throw error; - } - } + // Config + private rpcUrl?: string; + grantContracts?: ContractGrantDescription[]; + stake?: boolean; + bank?: SpendLimit[]; + callbackUrl?: string; + treasury?: string; + private autoLogoutOnGrantChange = true; + + // Signer + private client?: GranteeSignerClient; + private cosmwasmQueryClient?: CosmWasmClient; + + // Accounts + abstractAccount?: SignArbSecp256k1HdWallet; + + // State + private isLoginInProgress = false; + isLoggedIn = false; + grantsChanged = false; + authStateChangeSubscribers: ((isLoggedIn: boolean) => void)[] = []; + + /** + * Creates an instance of the AbstraxionAuth class. + */ + constructor( + private storageStrategy: StorageStrategy, + private redirectStrategy: RedirectStrategy, + ) { + // Specific to mobile flow + if (this.redirectStrategy.onRedirectComplete) { + this.redirectStrategy.onRedirectComplete(async (params) => { + if (params.granter) { + await this.setGranter(params.granter); + await this.login(); + } + }); + } + } + + /** + * Updates AbstraxionAuth instance with user config + * + * @param {string} rpc - The RPC URL used for communication with the blockchain. + * @param {ContractGrantDescription[]} [grantContracts] - Contracts for granting permissions. + * @param {boolean} [stake] - Indicates whether staking is enabled. + * @param {SpendLimit[]} [bank] - The spend limits for the user. + * @param {string} callbackUrl - preferred callback url to override default + * @param {string} treasury - treasury contract instance address + * @param {boolean} autoLogoutOnGrantChange - whether to automatically logout when grant changes are detected (defaults to true) + */ + configureAbstraxionInstance( + rpc: string, + grantContracts?: ContractGrantDescription[], + stake?: boolean, + bank?: SpendLimit[], + callbackUrl?: string, + treasury?: string, + autoLogoutOnGrantChange?: boolean, + ) { + this.rpcUrl = rpc; + this.grantContracts = grantContracts; + this.stake = stake; + this.bank = bank; + this.callbackUrl = callbackUrl; + this.treasury = treasury; + this.autoLogoutOnGrantChange = autoLogoutOnGrantChange ?? true; + } + + /** + * Subscribes to changes in authentication state. + * When the authentication state changes, the provided callback function is invoked + * with the new authentication state (isLoggedIn). + * Returns an unsubscribe function that can be called to remove the subscription. + * + * @param {function} callback - A function to be invoked when the authentication state changes. + * Receives a single parameter, isLoggedIn, indicating whether the user is logged in. + * The callback should accept a boolean parameter. + * @returns {function} - A function that, when called, removes the subscription to authentication state changes. + * This function should be invoked to clean up the subscription when no longer needed. + */ + subscribeToAuthStateChange(callback: (isLoggedIn: boolean) => void) { + this.authStateChangeSubscribers.push(callback); + return () => { + const index = this.authStateChangeSubscribers.indexOf(callback); + if (index !== -1) { + this.authStateChangeSubscribers.splice(index, 1); + } + }; + } + + /** + * Triggers a change in authentication state and notifies all subscribers. + * + * @param {boolean} isLoggedIn - The new authentication state, indicating whether the user is logged in. + */ + private triggerAuthStateChange(isLoggedIn: boolean): void { + this.isLoggedIn = isLoggedIn; + this.authStateChangeSubscribers.forEach((callback) => callback(isLoggedIn)); + } + + /** + * Get the account address of the granter from persisted state. + * + * @returns {string} The account address of the granter wallet (XION Meta Account). + */ + async getGranter(): Promise { + const granterAddress = await this.storageStrategy.getItem( + "xion-authz-granter-account", + ); + if ( + !granterAddress || + granterAddress === undefined || + granterAddress === "undefined" + ) { + return ""; + } + return granterAddress; + } + + /** + * Remove persisted instance of granter account. + */ + private async removeGranterAddress(): Promise { + await this.storageStrategy.removeItem("xion-authz-granter-account"); + } + + /** + * Set a persisted instance for granter account. + * + * @param {string} address - account address of the granter wallet (XION Meta Account). + */ + private async setGranter(address: string): Promise { + await this.storageStrategy.setItem("xion-authz-granter-account", address); + } + + /** + * Get temp keypair from persisted state. + */ + async getLocalKeypair(): Promise { + const localKeypair = await this.storageStrategy.getItem( + "xion-authz-temp-account", + ); + if (!localKeypair) { + return undefined; + } + return await SignArbSecp256k1HdWallet.deserialize( + localKeypair, + "abstraxion", + ); + } + + /** + * Generate a new temp keypair and store in persisted state. + */ + async generateAndStoreTempAccount(): Promise { + const keypair = await SignArbSecp256k1HdWallet.generate(12, { + prefix: "xion", + }); + + const serializedKeypair = await keypair.serialize("abstraxion"); + await this.storageStrategy.setItem( + "xion-authz-temp-account", + serializedKeypair, + ); + + await this.removeGranterAddress(); // Prevent multiple truth issue + + return keypair; + } + + /** + * Get keypair account address. + */ + async getKeypairAddress(): Promise { + const keypair = await this.getLocalKeypair(); + if (!keypair) return ""; + const accounts = await keypair.getAccounts(); + const address = accounts[0].address; + return address; + } + + /** + * Get GranteeSignerClient for the temp keypair. + */ + async getSigner(): Promise { + try { + if (this.client) { + return this.client; + } + + if (!this.rpcUrl) { + throw new Error("Configuration not initialized"); + } + + if (!this.abstractAccount) { + throw new Error("No account found."); + } + + const granterAddress = await this.getGranter(); + + if (!granterAddress) { + throw new Error("No granter found."); + } + + const granteeAddress = await this.abstractAccount + .getAccounts() + .then((accounts: any) => { + if (accounts.length === 0) { + throw new Error("No account found."); + } + return accounts[0].address; + }); + + const directClient = await GranteeSignerClient.connectWithSigner( + this.rpcUrl, + this.abstractAccount, + { + gasPrice: GasPrice.fromString("0uxion"), + granterAddress, + granteeAddress, + treasuryAddress: this.treasury, + }, + ); + + this.client = directClient; + return directClient; + } catch (error) { + console.warn("Something went wrong getting signer: ", error); + this.client = undefined; + throw error; + } + } + + /** + * Get non-signing CosmWasmClient + * @returns {Promise} A Promise that resolves to a CosmWasmClient + * @throws {Error} If the rpcUrl is missing, or if there is a network issue. + */ + async getCosmWasmClient(): Promise { + try { + if (this.cosmwasmQueryClient) { + return this.cosmwasmQueryClient; + } + + if (!this.rpcUrl) { + throw new Error("Configuration not initialized"); + } + + const cosmwasmClient = await CosmWasmClient.connect(this.rpcUrl || ""); + + this.cosmwasmQueryClient = cosmwasmClient; + return cosmwasmClient; + } catch (error) { + console.warn("Something went wrong getting cosmwasm client: ", error); + this.cosmwasmQueryClient = undefined; + throw error; + } + } + + /** + * Get dashboard url and redirect in order to issue claim with XION meta account for local keypair. + */ + async redirectToDashboard() { + try { + if (!this.rpcUrl) { + throw new Error("AbstraxionAuth needs to be configured."); + } + const userAddress = await this.getKeypairAddress(); + const { dashboardUrl } = await fetchConfig(this.rpcUrl); + await this.configureUrlAndRedirect(dashboardUrl, userAddress); + } catch (error) { + console.warn( + "Something went wrong trying to redirect to XION dashboard: ", + error, + ); + } + } + + /** + * Configure URL and redirect page + */ + private async configureUrlAndRedirect( + dashboardUrl: string, + userAddress: string, + ): Promise { + if (typeof window !== "undefined") { + const currentUrl = this.callbackUrl || window.location.href; + const urlParams = new URLSearchParams(); + + if (this.treasury) { + urlParams.set("treasury", this.treasury); + } + + if (this.bank) { + urlParams.set("bank", JSON.stringify(this.bank)); + } + + if (this.stake) { + urlParams.set("stake", "true"); + } + + if (this.grantContracts) { + urlParams.set("contracts", JSON.stringify(this.grantContracts)); + } + + urlParams.set("grantee", userAddress); + urlParams.set("redirect_uri", currentUrl); + + const queryString = urlParams.toString(); + await this.redirectStrategy.redirect(`${dashboardUrl}?${queryString}`); + } else { + console.warn("Window not defined. Cannot redirect to dashboard"); + } + } + + /** + * Compares a GrantsResponse object to the legacy configuration stored in the instance. + * Validates the presence and attributes of grants for each authorization type. + * + * @param {GrantsResponse} grantsResponse - The grants response object containing the chain grants. + * @returns {boolean} - Returns `true` if the grants match the expected configuration; otherwise, `false`. + */ + compareGrantsToLegacyConfig(grantsResponse: GrantsResponse): boolean { + const { grants } = grantsResponse; + + return ( + compareContractGrants(grants, this.grantContracts) && + compareStakeGrants(grants, this.stake) && + compareBankGrants(grants, this.bank) + ); + } + + /** + * Compares treasury grant configurations with the grants on-chain to ensure they match. + * + * @param {GrantsResponse} grantsResponse - The grants currently existing on-chain. + * @returns {Promise} - Returns a promise that resolves to `true` if all treasury grants match chain grants; otherwise, `false`. + * @throws {Error} - Throws an error if the treasury contract is missing. + */ + async compareGrantsToTreasury( + grantsResponse: GrantsResponse, + ): Promise { + if (!this.treasury) { + throw new Error("Missing treasury"); + } + + const cosmwasmClient = + this.cosmwasmQueryClient || (await this.getCosmWasmClient()); + + const treasuryTypeUrls = await getTreasuryContractTypeUrls( + cosmwasmClient, + this.treasury, + ); + const treasuryGrantConfigs = await getTreasuryContractConfigsByTypeUrl( + cosmwasmClient, + this.treasury, + treasuryTypeUrls, + ); + + const decodedTreasuryConfigs: DecodedReadableAuthorization[] = + treasuryGrantConfigs.map((treasuryGrantConfig) => { + return decodeAuthorization( + treasuryGrantConfig.authorization.type_url, + treasuryGrantConfig.authorization.value, + ); + }); + + const decodedChainConfigs: DecodedReadableAuthorization[] = + grantsResponse.grants.map((grantResponse) => { + return decodeAuthorization( + grantResponse.authorization.typeUrl, + grantResponse.authorization.value, + ); + }); + + return compareChainGrantsToTreasuryGrants( + decodedChainConfigs, + decodedTreasuryConfigs, + ); + } + + /** + * Poll for grants issued to a grantee from a granter. + * + * @param {string} grantee - The address of the grantee. + * @param {string | null} granter - The address of the granter, or null if not available. + * @returns {Promise} A Promise that resolves to true if grants are found, otherwise false. + * @throws {Error} If the grantee or granter address is invalid, or if maximum retries are exceeded. + */ + async pollForGrants( + grantee: string, + granter: string | null, + ): Promise { + if (!this.rpcUrl) { + throw new Error("AbstraxionAuth needs to be configured."); + } + if (!grantee) { + throw new Error("No keypair address"); + } + if (!granter) { + throw new Error("No granter address"); + } + + const maxRetries = 5; + let retries = 0; + + while (retries < maxRetries) { + try { + const data = await fetchChainGrantsABCI(grantee, granter, this.rpcUrl); + + if (data.grants.length === 0) { + console.warn("No grants found."); + return false; + } + + // Check expiration for each grant + const currentTime = new Date().toISOString(); + const validGrant = data.grants.some((grant) => { + const { expiration } = grant; + return !expiration || expiration > currentTime; + }); + + let isValid: boolean; + if (this.treasury) { + isValid = await this.compareGrantsToTreasury(data); + } else { + isValid = this.compareGrantsToLegacyConfig(data); + } + + return validGrant && isValid; + } catch (error) { + console.warn("Error fetching grants: ", error); + const delay = Math.pow(2, retries) * 1000; + await new Promise((resolve) => setTimeout(resolve, delay)); + retries++; + } + } + console.error("Max retries exceeded, giving up."); + return false; + } + + /** + * Wipe persisted state and instance variables. + */ + async logout(): Promise { + await Promise.all([ + this.storageStrategy.removeItem("xion-authz-temp-account"), + this.storageStrategy.removeItem("xion-authz-granter-account"), + ]); + this.abstractAccount = undefined; + this.triggerAuthStateChange(false); + } + + /** + * Authenticates the user based on the presence of a local keypair and a granter address. + * Also checks if the grant is still valid by verifying the expiration and configuration changes. + * If valid, sets the abstract account and triggers authentication state change. + * If expired or configuration changed, clears local state and prompts reauthorization. + * + * @returns {Promise} - Resolves if authentication is successful or logs out the user otherwise. + */ + async authenticate(): Promise { + try { + const keypair = await this.getLocalKeypair(); + const granter = await this.getGranter(); + + if (!keypair || !granter) { + console.warn("Missing keypair or granter, cannot authenticate."); + return; + } + + const accounts = await keypair.getAccounts(); + const keypairAddress = accounts[0].address; + + // Reset grants changed flag at the start of authentication + this.grantsChanged = false; + + // Get current grants and perform comprehensive validation + const grantsResponse = await fetchChainGrantsABCI( + keypairAddress, + granter, + this.rpcUrl!, + ); + + if (grantsResponse.grants.length === 0) { + throw new Error("No grants found."); + } + + // Check expiration for each grant + const currentTime = new Date().toISOString(); + const validGrant = grantsResponse.grants.some((grant) => { + const { expiration } = grant; + return !expiration || expiration > currentTime; + }); + + if (!validGrant) { + throw new Error("Grants have expired."); + } + + // Perform comprehensive grant validation to detect configuration changes + let grantsMatch: boolean; + if (this.treasury) { + grantsMatch = await this.compareGrantsToTreasury(grantsResponse); + } else { + grantsMatch = this.compareGrantsToLegacyConfig(grantsResponse); + } + + // Handle grant configuration changes + if (!grantsMatch) { + // Only set grantsChanged if not in login progress + if (!this.isLoginInProgress) { + this.grantsChanged = true; + } + + if (this.autoLogoutOnGrantChange) { + throw new Error( + "Grant configuration has changed. Auto-logout enabled.", + ); + } + } + + // If we reach here, grants are valid (either matching or auto-logout is disabled) + this.abstractAccount = keypair; + this.triggerAuthStateChange(true); + } catch (error) { + console.error("Error during authentication:", error); + await this.logout(); + } + } + /** + * Get the current grants changed state. + * + * @returns {boolean} - Returns true if grants have changed since last authentication. + */ + getGrantsChanged(): boolean { + return this.grantsChanged; + } + + /** + * Initiates the login process for the user. + * Checks if a local keypair and granter address exist, either from URL parameters or this.storageStrategy. + * If both exist, polls for grants and updates the authentication state if successful. + * If not, generates a new keypair and redirects to the dashboard for grant issuance. + * + * @returns {Promise} - A Promise that resolves once the login process is complete. + * @throws {Error} - If the login process encounters an error. + */ + async login(): Promise { + try { + if (this.isLoginInProgress) { + console.warn("Login is already in progress."); + return; + } + this.isLoginInProgress = true; + // Get local keypair and granter address from either URL param (if new) or this.storageStrategy (if existing) + const keypair = await this.getLocalKeypair(); + const storedGranter = await this.getGranter(); + const urlGranter = await this.redirectStrategy.getUrlParameter("granter"); + const granter = storedGranter || urlGranter; + + // If both exist, we can assume user is either 1. already logged in and grants have been created for the temp key, or 2. been redirected with the granter url param + // In either case, we poll for grants and make the appropriate state changes to reflect a "logged in" state + if (keypair && granter) { + const accounts = await keypair.getAccounts(); + const keypairAddress = accounts[0].address; + const pollSuccess = await this.pollForGrants(keypairAddress, granter); + if (!pollSuccess) { + throw new Error("Poll was unsuccessful. Please try again"); + } + + this.setGranter(granter); + this.abstractAccount = keypair; + this.triggerAuthStateChange(true); + + if (typeof window !== "undefined") { + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete("granted"); + currentUrl.searchParams.delete("granter"); + history.pushState({}, "", currentUrl.href); + } + } else { + // If there isn't an existing keypair, or there isn't a granter in either this.storageStrategy or the url params, we want to start from scratch + // Generate new keypair and redirect to dashboard + await this.newKeypairFlow(); + } + return; + } catch (error) { + console.warn("Something went wrong: ", error); + throw error; + } finally { + this.isLoginInProgress = false; + } + } + + /** + * Initiates the flow to generate a new keypair and redirect to the dashboard for grant issuance. + */ + private async newKeypairFlow(): Promise { + try { + await this.generateAndStoreTempAccount(); + await this.redirectToDashboard(); + } catch (error) { + console.warn("Something went wrong: ", error); + throw error; + } + } } diff --git a/packages/abstraxion-react-native/README.md b/packages/abstraxion-react-native/README.md index bcf01cd7..acac5a6c 100644 --- a/packages/abstraxion-react-native/README.md +++ b/packages/abstraxion-react-native/README.md @@ -8,6 +8,7 @@ A React Native implementation of the Abstraxion authentication system for XION b - Storage strategy using AsyncStorage instead of localStorage - Redirect handling using Expo WebBrowser instead of browser navigation - React Native optimized hooks with the exact same interfaces as @burnt-labs/abstraxion +- Auto logout on grant changes for enhanced security ## Installation @@ -67,6 +68,7 @@ interface AbstraxionConfig { // Optional configurations treasury?: string; // Treasury contract address callbackUrl?: string; // Callback URL after authorization + autoLogoutOnGrantChange?: boolean; // Auto logout when grants change (defaults to true) } // Contract authorization can be either a string or an object @@ -84,6 +86,71 @@ interface SpendLimit { } ``` +## Security Features + +### Auto Logout on Grant Changes + +Abstraxion React Native includes an optional security feature that automatically logs out users when treasury contract grant configurations change between app sessions. This prevents users from operating with outdated or incorrect permissions. + +#### Configuration + +Auto logout is enabled by default. To disable it for custom handling, set `autoLogoutOnGrantChange: false`: + +```tsx +const config = { + treasury: "xion13jetl8j9kcgsva86l08kpmy8nsnzysyxs06j4s69c6f7ywu7q36q4k5smc", + autoLogoutOnGrantChange: false, // Disable auto logout for custom handling + gasPrice: "0.001uxion", +}; +``` + +#### Detecting Grant Changes + +Access the `grantsChanged` property from the `useAbstraxionAccount` hook to implement custom handling: + +```tsx +import { useAbstraxionAccount } from "@burnt-labs/abstraxion-react-native"; +import { useEffect } from "react"; +import { Alert } from "react-native"; + +const MyComponent = () => { + const { + data: account, + grantsChanged, + isConnected, + login, + } = useAbstraxionAccount(); + + useEffect(() => { + if (grantsChanged && !isConnected) { + // Handle grant changes with custom UX + Alert.alert( + "Permissions Updated", + "The app's permissions have been updated. Please reconnect to continue.", + [{ text: "Reconnect", onPress: () => login() }], + ); + } + }, [grantsChanged, isConnected, login]); + + return ( + + {grantsChanged && !isConnected && ( + + Permissions have changed. Please reconnect. + + )} + + ); +}; +``` + +#### How It Works + +- **Startup Validation**: When the app starts, Abstraxion compares current on-chain grants with the expected treasury configuration +- **Automatic Logout**: If `autoLogoutOnGrantChange` is enabled (default) and changes are detected, users are automatically logged out +- **Developer Control**: The `grantsChanged` property allows developers to implement custom notifications and handling +- **Session Safety**: The feature only triggers during app startup, not during active login processes + ## Available Hooks This package provides the exact same hooks as @burnt-labs/abstraxion: @@ -99,11 +166,12 @@ The hooks in this package maintain a similar interface to @burnt-labs/abstraxion ```typescript // useAbstraxionAccount -const { data, isConnected, isConnecting, login, logout } = +const { data, isConnected, isConnecting, grantsChanged, login, logout } = useAbstraxionAccount(); // data.bech32Address: string - The connected wallet address // isConnected: boolean - Whether a wallet is connected // isConnecting: boolean - Whether a connection is in progress +// grantsChanged: boolean - Whether grant configuration has changed since last session // login: () => Promise - Function to initiate wallet connection // logout: () => void - Function to disconnect the wallet @@ -184,6 +252,51 @@ For authentication to work properly, your app should be configured to handle dee The authentication flow uses Expo's WebBrowser with authentication sessions, which will automatically handle the redirect flow when the user completes authentication. +## Best Practices + +### Grant Change Handling + +When using the auto logout feature, consider these best practices: + +1. **User Communication**: Always inform users why they were logged out +2. **Graceful Reconnection**: Provide easy reconnection options +3. **State Preservation**: Save user progress before logout when possible +4. **Custom Notifications**: Use platform-appropriate alerts (Alert.alert for React Native) + +Example implementation: + +```tsx +import { useAbstraxionAccount } from "@burnt-labs/abstraxion-react-native"; +import { useEffect } from "react"; +import { Alert } from "react-native"; + +const useGrantChangeHandler = () => { + const { grantsChanged, isConnected, login } = useAbstraxionAccount(); + + useEffect(() => { + if (grantsChanged && !isConnected) { + Alert.alert( + "Security Update", + "Your app permissions have been updated for security. Please sign in again to continue.", + [ + { text: "Later", style: "cancel" }, + { text: "Sign In", onPress: login } + ] + ); + } + }, [grantsChanged, isConnected, login]); +}; + +// Usage in your component +const MyApp = () => { + useGrantChangeHandler(); + + return ( + // Your app content + ); +}; +``` + ## License MIT diff --git a/packages/abstraxion-react-native/src/components/AbstraxionContext/index.tsx b/packages/abstraxion-react-native/src/components/AbstraxionContext/index.tsx index 6eb5bca3..5cd2bb83 100644 --- a/packages/abstraxion-react-native/src/components/AbstraxionContext/index.tsx +++ b/packages/abstraxion-react-native/src/components/AbstraxionContext/index.tsx @@ -2,191 +2,207 @@ import { createContext, useCallback, useEffect, useState } from "react"; import { testnetChainInfo, xionGasValues } from "@burnt-labs/constants"; import { GasPrice } from "@cosmjs/stargate"; import { - AbstraxionAuth, - SignArbSecp256k1HdWallet, + AbstraxionAuth, + type SignArbSecp256k1HdWallet, } from "@burnt-labs/abstraxion-core"; import { - ReactNativeRedirectStrategy, - ReactNativeStorageStrategy, + ReactNativeRedirectStrategy, + ReactNativeStorageStrategy, } from "../../strategies"; export const abstraxionAuth = new AbstraxionAuth( - new ReactNativeStorageStrategy(), - new ReactNativeRedirectStrategy(), + new ReactNativeStorageStrategy(), + new ReactNativeRedirectStrategy(), ); export type SpendLimit = { denom: string; amount: string }; export type ContractGrantDescription = - | string - | { - address: string; - amounts: SpendLimit[]; - }; + | string + | { + address: string; + amounts: SpendLimit[]; + }; export interface AbstraxionContextProps { - isConnected: boolean; - setIsConnected: React.Dispatch>; - isConnecting: boolean; - setIsConnecting: React.Dispatch>; - abstraxionError: string; - setAbstraxionError: React.Dispatch>; - abstraxionAccount: SignArbSecp256k1HdWallet | undefined; - setAbstraxionAccount: React.Dispatch; - granterAddress: string; - setGranterAddress: React.Dispatch>; - contracts?: ContractGrantDescription[]; - dashboardUrl?: string; - setDashboardUrl: React.Dispatch>; - rpcUrl: string; - stake?: boolean; - bank?: SpendLimit[]; - treasury?: string; - gasPrice: GasPrice; - logout: () => void; - login: () => Promise; + isConnected: boolean; + setIsConnected: React.Dispatch>; + isConnecting: boolean; + setIsConnecting: React.Dispatch>; + abstraxionError: string; + setAbstraxionError: React.Dispatch>; + abstraxionAccount: SignArbSecp256k1HdWallet | undefined; + setAbstraxionAccount: React.Dispatch; + granterAddress: string; + setGranterAddress: React.Dispatch>; + contracts?: ContractGrantDescription[]; + dashboardUrl?: string; + setDashboardUrl: React.Dispatch>; + rpcUrl: string; + stake?: boolean; + bank?: SpendLimit[]; + treasury?: string; + gasPrice: GasPrice; + grantsChanged: boolean; + logout: () => void; + login: () => Promise; } export interface AbstraxionConfig { - contracts?: ContractGrantDescription[]; - rpcUrl?: string; - stake?: boolean; - bank?: SpendLimit[]; - callbackUrl?: string; - treasury?: string; - gasPrice?: string; + contracts?: ContractGrantDescription[]; + rpcUrl?: string; + stake?: boolean; + bank?: SpendLimit[]; + callbackUrl?: string; + treasury?: string; + gasPrice?: string; + autoLogoutOnGrantChange?: boolean; } export function AbstraxionProvider({ - children, - config: { - contracts, - rpcUrl = testnetChainInfo.rpc, - stake = false, - bank, - callbackUrl, - treasury, - gasPrice, - }, + children, + config: { + contracts, + rpcUrl = testnetChainInfo.rpc, + stake = false, + bank, + callbackUrl, + treasury, + gasPrice, + autoLogoutOnGrantChange, + }, }: { - children: React.ReactNode; - config: AbstraxionConfig; + children: React.ReactNode; + config: AbstraxionConfig; }): JSX.Element { - const [abstraxionError, setAbstraxionError] = useState(""); - const [isConnected, setIsConnected] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [abstraxionAccount, setAbstraxionAccount] = useState< - SignArbSecp256k1HdWallet | undefined - >(undefined); - const [granterAddress, setGranterAddress] = useState(""); - const [dashboardUrl, setDashboardUrl] = useState(""); - let gasPriceDefault: GasPrice; - const { gasPrice: gasPriceConstant } = xionGasValues; - if (rpcUrl.includes("mainnet")) { - gasPriceDefault = GasPrice.fromString(gasPriceConstant); - } else { - gasPriceDefault = GasPrice.fromString("0.001uxion"); - } - - const configureInstance = useCallback(() => { - abstraxionAuth.configureAbstraxionInstance( - rpcUrl, - contracts, - stake, - bank, - callbackUrl, - treasury, - ); - }, [rpcUrl, contracts, stake, bank, callbackUrl, treasury]); - - useEffect(() => { - configureInstance(); - }, [configureInstance]); - - useEffect(() => { - const unsubscribe = abstraxionAuth.subscribeToAuthStateChange( - async (newState: boolean) => { - if (newState !== isConnected) { - setIsConnected(newState); - if (newState) { - const account = await abstraxionAuth.getLocalKeypair(); - const granterAddress = await abstraxionAuth.getGranter(); - setAbstraxionAccount(account); - setGranterAddress(granterAddress); - } - } - }, - ); - - return () => { - unsubscribe?.(); - }; - }, [isConnected, abstraxionAuth]); - - const persistAuthenticateState = useCallback(async () => { - await abstraxionAuth.authenticate(); - }, [abstraxionAuth]); - - useEffect(() => { - if (!isConnecting && !abstraxionAccount && !granterAddress) { - persistAuthenticateState(); - } - }, [ - isConnecting, - abstraxionAccount, - granterAddress, - persistAuthenticateState, - ]); - - async function login() { - try { - setIsConnecting(true); - await abstraxionAuth.login(); - } catch (error) { - console.log(error); - throw error; // Re-throw to allow handling by the caller - } finally { - setIsConnecting(false); - } - } - - const logout = useCallback(() => { - setIsConnected(false); - setAbstraxionAccount(undefined); - setGranterAddress(""); - abstraxionAuth?.logout(); - }, [abstraxionAuth]); - - return ( - - {children} - - ); + const [abstraxionError, setAbstraxionError] = useState(""); + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [abstraxionAccount, setAbstraxionAccount] = useState< + SignArbSecp256k1HdWallet | undefined + >(undefined); + const [granterAddress, setGranterAddress] = useState(""); + const [dashboardUrl, setDashboardUrl] = useState(""); + const [grantsChanged, setGrantsChanged] = useState(false); + let gasPriceDefault: GasPrice; + const { gasPrice: gasPriceConstant } = xionGasValues; + if (rpcUrl.includes("mainnet")) { + gasPriceDefault = GasPrice.fromString(gasPriceConstant); + } else { + gasPriceDefault = GasPrice.fromString("0.001uxion"); + } + + const configureInstance = useCallback(() => { + abstraxionAuth.configureAbstraxionInstance( + rpcUrl, + contracts, + stake, + bank, + callbackUrl, + treasury, + autoLogoutOnGrantChange, + ); + }, [ + rpcUrl, + contracts, + stake, + bank, + callbackUrl, + treasury, + autoLogoutOnGrantChange, + ]); + + useEffect(() => { + configureInstance(); + }, [configureInstance]); + + useEffect(() => { + const unsubscribe = abstraxionAuth.subscribeToAuthStateChange( + async (newState: boolean) => { + if (newState !== isConnected) { + setIsConnected(newState); + // Update grants changed state + setGrantsChanged(abstraxionAuth.getGrantsChanged()); + if (newState) { + const account = await abstraxionAuth.getLocalKeypair(); + const granterAddress = await abstraxionAuth.getGranter(); + setAbstraxionAccount(account); + setGranterAddress(granterAddress); + } + } + }, + ); + + return () => { + unsubscribe?.(); + }; + }, [isConnected]); + + const persistAuthenticateState = useCallback(async () => { + await abstraxionAuth.authenticate(); + }, []); + + useEffect(() => { + if (!isConnecting && !abstraxionAccount && !granterAddress) { + persistAuthenticateState(); + } + }, [ + isConnecting, + abstraxionAccount, + granterAddress, + persistAuthenticateState, + ]); + + async function login() { + try { + setIsConnecting(true); + await abstraxionAuth.login(); + } catch (error) { + console.log(error); + throw error; // Re-throw to allow handling by the caller + } finally { + setIsConnecting(false); + } + } + + const logout = useCallback(() => { + setIsConnected(false); + setAbstraxionAccount(undefined); + setGranterAddress(""); + abstraxionAuth?.logout(); + }, []); + + return ( + + {children} + + ); } export const AbstraxionContext = createContext( - {} as AbstraxionContextProps, + {} as AbstraxionContextProps, ); diff --git a/packages/abstraxion/README.md b/packages/abstraxion/README.md index e6d745c0..4513a898 100644 --- a/packages/abstraxion/README.md +++ b/packages/abstraxion/README.md @@ -62,6 +62,7 @@ const legacyConfig = { // New treasury contract config const treasuryConfig = { treasury: "xion17ah4x9te3sttpy2vj5x6hv4xvc0td526nu0msf7mt3kydqj4qs2s9jhe90", // Example XION treasury contract + autoLogoutOnGrantChange: false, // Disable auto logout for custom handling // Optional params to activate mainnet config // rpcUrl: "https://rpc.xion-mainnet-1.burnt.com:443", // restUrl: "https://api.xion-mainnet-1.burnt.com:443", @@ -103,10 +104,90 @@ export default function Home() { ``` -Finally, call the exported hooks to serve your functionality needs: +## Security Features + +### Auto Logout on Grant Changes + +Abstraxion includes an optional security feature that automatically logs out users when treasury contract grant configurations change between app sessions. This prevents users from operating with outdated or incorrect permissions. + +#### Configuration +Auto logout is enabled by default. To disable it for custom handling, set `autoLogoutOnGrantChange: false`: + +```tsx +const treasuryConfig = { + treasury: "xion17ah4x9te3sttpy2vj5x6hv4xvc0td526nu0msf7mt3kydqj4qs2s9jhe90", + autoLogoutOnGrantChange: false, // Disable auto logout for custom handling + gasPrice: "0.001uxion", +}; ``` -const { data: account } = useAbstraxionAccount(); + +#### Detecting Grant Changes + +Access the `grantsChanged` property from the `useAbstraxionAccount` hook to implement custom handling: + +```tsx +import { useAbstraxionAccount } from "@burnt-labs/abstraxion"; +import { useEffect } from "react"; + +const MyComponent = () => { + const { + data: account, + grantsChanged, + isConnected, + login, + } = useAbstraxionAccount(); + + useEffect(() => { + if (grantsChanged && !isConnected) { + // Handle grant changes with custom UX + alert( + "The app's permissions have been updated. Please reconnect to continue.", + ); + } + }, [grantsChanged, isConnected]); + + return ( +
+ {grantsChanged && !isConnected && ( +
+ Permissions have changed. Please reconnect. + +
+ )} +
+ ); +}; +``` + +#### How It Works + +- **Startup Validation**: When the app starts, Abstraxion compares current on-chain grants with the expected treasury configuration +- **Automatic Logout**: If `autoLogoutOnGrantChange` is enabled (default) and changes are detected, users are automatically logged out +- **Developer Control**: The `grantsChanged` property allows developers to implement custom notifications and handling +- **Session Safety**: The feature only triggers during app startup, not during active login processes + +## Hook Usage + +Finally, call the exported hooks to serve your functionality needs: + +```tsx +// Basic account information and grant status +const { + data: account, + grantsChanged, + isConnected, + login, + logout, +} = useAbstraxionAccount(); + +// Signing client for transactions const { client } = useAbstraxionSigningClient(); ``` diff --git a/packages/abstraxion/src/components/Abstraxion/index.tsx b/packages/abstraxion/src/components/Abstraxion/index.tsx index db4ce6b6..35c3a926 100644 --- a/packages/abstraxion/src/components/Abstraxion/index.tsx +++ b/packages/abstraxion/src/components/Abstraxion/index.tsx @@ -76,6 +76,7 @@ export interface AbstraxionConfig { callbackUrl?: string; treasury?: string; gasPrice?: string; + enableLogoutOnGrantChange?: boolean; } export function AbstraxionProvider({ @@ -94,6 +95,7 @@ export function AbstraxionProvider({ callbackUrl={config.callbackUrl} treasury={config.treasury} gasPrice={config.gasPrice} + enableLogoutOnGrantChange={config.enableLogoutOnGrantChange} > {children} diff --git a/packages/abstraxion/src/components/AbstraxionContext/index.tsx b/packages/abstraxion/src/components/AbstraxionContext/index.tsx index 7f54e443..939e76d0 100644 --- a/packages/abstraxion/src/components/AbstraxionContext/index.tsx +++ b/packages/abstraxion/src/components/AbstraxionContext/index.tsx @@ -2,197 +2,213 @@ import type { ReactNode } from "react"; import { createContext, useCallback, useEffect, useState } from "react"; import { testnetChainInfo, xionGasValues } from "@burnt-labs/constants"; import { GasPrice } from "@cosmjs/stargate"; -import { SignArbSecp256k1HdWallet } from "@burnt-labs/abstraxion-core"; +import type { SignArbSecp256k1HdWallet } from "@burnt-labs/abstraxion-core"; import { abstraxionAuth } from "../Abstraxion"; export type SpendLimit = { denom: string; amount: string }; export type ContractGrantDescription = - | string - | { - address: string; - amounts: SpendLimit[]; - }; + | string + | { + address: string; + amounts: SpendLimit[]; + }; export interface AbstraxionContextProps { - isConnected: boolean; - setIsConnected: React.Dispatch>; - isConnecting: boolean; - setIsConnecting: React.Dispatch>; - abstraxionError: string; - setAbstraxionError: React.Dispatch>; - abstraxionAccount: SignArbSecp256k1HdWallet | undefined; - setAbstraxionAccount: React.Dispatch; - granterAddress: string; - showModal: boolean; - setShowModal: React.Dispatch>; - setGranterAddress: React.Dispatch>; - contracts?: ContractGrantDescription[]; - dashboardUrl?: string; - setDashboardUrl: React.Dispatch>; - rpcUrl: string; - stake?: boolean; - bank?: SpendLimit[]; - treasury?: string; - gasPrice: GasPrice; - logout: () => void; - login: () => Promise; + isConnected: boolean; + setIsConnected: React.Dispatch>; + isConnecting: boolean; + setIsConnecting: React.Dispatch>; + abstraxionError: string; + setAbstraxionError: React.Dispatch>; + abstraxionAccount: SignArbSecp256k1HdWallet | undefined; + setAbstraxionAccount: React.Dispatch; + granterAddress: string; + showModal: boolean; + setShowModal: React.Dispatch>; + setGranterAddress: React.Dispatch>; + contracts?: ContractGrantDescription[]; + dashboardUrl?: string; + setDashboardUrl: React.Dispatch>; + rpcUrl: string; + stake?: boolean; + bank?: SpendLimit[]; + treasury?: string; + gasPrice: GasPrice; + grantsChanged: boolean; + logout: () => void; + login: () => Promise; } export const AbstraxionContext = createContext( - {} as AbstraxionContextProps, + {} as AbstraxionContextProps, ); export function AbstraxionContextProvider({ - children, - contracts, - rpcUrl = testnetChainInfo.rpc, - stake = false, - bank, - callbackUrl, - treasury, - gasPrice, + children, + contracts, + rpcUrl = testnetChainInfo.rpc, + stake = false, + bank, + callbackUrl, + treasury, + gasPrice, + autoLogoutOnGrantChange, }: { - children: ReactNode; - contracts?: ContractGrantDescription[]; - dashboardUrl?: string; - rpcUrl?: string; - stake?: boolean; - bank?: SpendLimit[]; - callbackUrl?: string; - treasury?: string; - gasPrice?: string; + children: ReactNode; + contracts?: ContractGrantDescription[]; + dashboardUrl?: string; + rpcUrl?: string; + stake?: boolean; + bank?: SpendLimit[]; + callbackUrl?: string; + treasury?: string; + gasPrice?: string; + autoLogoutOnGrantChange?: boolean; }): JSX.Element { - const [abstraxionError, setAbstraxionError] = useState(""); - const [isConnected, setIsConnected] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [showModal, setShowModal] = useState(false); - const [abstraxionAccount, setAbstraxionAccount] = useState< - SignArbSecp256k1HdWallet | undefined - >(undefined); - const [granterAddress, setGranterAddress] = useState(""); - const [dashboardUrl, setDashboardUrl] = useState(""); - let gasPriceDefault: GasPrice; - const { gasPrice: gasPriceConstant } = xionGasValues; - if (rpcUrl.includes("mainnet")) { - gasPriceDefault = GasPrice.fromString(gasPriceConstant); - } else { - gasPriceDefault = GasPrice.fromString("0.001uxion"); - } - - const configureInstance = useCallback(() => { - abstraxionAuth.configureAbstraxionInstance( - rpcUrl, - contracts, - stake, - bank, - callbackUrl, - treasury, - ); - }, [rpcUrl, contracts, stake, bank, callbackUrl, treasury]); - - useEffect(() => { - configureInstance(); - }, [configureInstance]); - - useEffect(() => { - const searchParams = new URLSearchParams(window.location.search); - if (searchParams.get("granted") === "true") { - setShowModal(true); - } - }, []); - - useEffect(() => { - const unsubscribe = abstraxionAuth.subscribeToAuthStateChange( - async (newState: boolean) => { - if (newState !== isConnected) { - setIsConnected(newState); - if (newState) { - const account = await abstraxionAuth.getLocalKeypair(); - const granterAddress = await abstraxionAuth.getGranter(); - setAbstraxionAccount(account); - setGranterAddress(granterAddress); - } - } - }, - ); - - return () => { - unsubscribe?.(); - }; - }, [isConnected, abstraxionAuth]); - - const persistAuthenticateState = useCallback(async () => { - await abstraxionAuth.authenticate(); - }, [abstraxionAuth]); - - useEffect(() => { - if (!isConnecting && !abstraxionAccount && !granterAddress) { - persistAuthenticateState(); - } - }, [ - isConnecting, - abstraxionAccount, - granterAddress, - persistAuthenticateState, - ]); - - async function login() { - try { - setIsConnecting(true); - await abstraxionAuth.login(); - } catch (error) { - console.log(error); - throw error; // Re-throw to allow handling by the caller - } finally { - setIsConnecting(false); - } - } - - useEffect(() => { - const searchParams = new URLSearchParams(window.location.search); - if (searchParams.get("granted") === "true") { - login().catch((error) => { - console.error("Failed to finish login:", error); - }); - } - }, []); - - const logout = useCallback(() => { - setIsConnected(false); - setAbstraxionAccount(undefined); - setGranterAddress(""); - abstraxionAuth?.logout(); - }, [abstraxionAuth]); - - return ( - - {children} - - ); + const [abstraxionError, setAbstraxionError] = useState(""); + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [showModal, setShowModal] = useState(false); + const [abstraxionAccount, setAbstraxionAccount] = useState< + SignArbSecp256k1HdWallet | undefined + >(undefined); + const [granterAddress, setGranterAddress] = useState(""); + const [dashboardUrl, setDashboardUrl] = useState(""); + const [grantsChanged, setGrantsChanged] = useState(false); + let gasPriceDefault: GasPrice; + const { gasPrice: gasPriceConstant } = xionGasValues; + if (rpcUrl.includes("mainnet")) { + gasPriceDefault = GasPrice.fromString(gasPriceConstant); + } else { + gasPriceDefault = GasPrice.fromString("0.001uxion"); + } + + const configureInstance = useCallback(() => { + abstraxionAuth.configureAbstraxionInstance( + rpcUrl, + contracts, + stake, + bank, + callbackUrl, + treasury, + autoLogoutOnGrantChange, + ); + }, [ + rpcUrl, + contracts, + stake, + bank, + callbackUrl, + treasury, + autoLogoutOnGrantChange, + ]); + + useEffect(() => { + configureInstance(); + }, [configureInstance]); + + useEffect(() => { + const searchParams = new URLSearchParams(window.location.search); + if (searchParams.get("granted") === "true") { + setShowModal(true); + } + }, []); + + useEffect(() => { + const unsubscribe = abstraxionAuth.subscribeToAuthStateChange( + async (newState: boolean) => { + if (newState !== isConnected) { + setIsConnected(newState); + // Update grants changed state + setGrantsChanged(abstraxionAuth.getGrantsChanged()); + if (newState) { + const account = await abstraxionAuth.getLocalKeypair(); + const granterAddress = await abstraxionAuth.getGranter(); + setAbstraxionAccount(account); + setGranterAddress(granterAddress); + } + } + }, + ); + + return () => { + unsubscribe?.(); + }; + }, [isConnected, abstraxionAuth]); + + const persistAuthenticateState = useCallback(async () => { + await abstraxionAuth.authenticate(); + }, [abstraxionAuth]); + + useEffect(() => { + if (!isConnecting && !abstraxionAccount && !granterAddress) { + persistAuthenticateState(); + } + }, [ + isConnecting, + abstraxionAccount, + granterAddress, + persistAuthenticateState, + ]); + + async function login() { + try { + setIsConnecting(true); + await abstraxionAuth.login(); + } catch (error) { + console.log(error); + throw error; // Re-throw to allow handling by the caller + } finally { + setIsConnecting(false); + } + } + + useEffect(() => { + const searchParams = new URLSearchParams(window.location.search); + if (searchParams.get("granted") === "true") { + login().catch((error) => { + console.error("Failed to finish login:", error); + }); + } + }, []); + + const logout = useCallback(() => { + setIsConnected(false); + setAbstraxionAccount(undefined); + setGranterAddress(""); + abstraxionAuth?.logout(); + }, [abstraxionAuth]); + + return ( + + {children} + + ); } diff --git a/packages/abstraxion/src/hooks/useAbstraxionAccount.ts b/packages/abstraxion/src/hooks/useAbstraxionAccount.ts index 86904feb..89b9e334 100644 --- a/packages/abstraxion/src/hooks/useAbstraxionAccount.ts +++ b/packages/abstraxion/src/hooks/useAbstraxionAccount.ts @@ -9,13 +9,20 @@ export interface AbstraxionAccountState { data: AbstraxionAccount; isConnected: boolean; isConnecting: boolean; + grantsChanged: boolean; login: () => Promise; logout: () => void; } export const useAbstraxionAccount = (): AbstraxionAccountState => { - const { isConnected, granterAddress, isConnecting, login, logout } = - useContext(AbstraxionContext); + const { + isConnected, + granterAddress, + isConnecting, + grantsChanged, + login, + logout, + } = useContext(AbstraxionContext); return { data: { @@ -23,6 +30,7 @@ export const useAbstraxionAccount = (): AbstraxionAccountState => { }, isConnected, isConnecting, + grantsChanged, login, logout, }; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index be74df15..2b557803 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -107,7 +107,9 @@ const DASHBOARD_URLS = { "xion-testnet-2": "https://auth.testnet.burnt.com", }; -export async function fetchConfig(rpcUrl: string) { +export async function fetchConfig( + rpcUrl: string, +): Promise<{ dashboardUrl: string }> { try { const fetchReq = await fetch(`${rpcUrl}/status`); if (!fetchReq.ok) { From ce6515d8b174bc06b16008564727677bc9e4c8c7 Mon Sep 17 00:00:00 2001 From: Bhiiktor Date: Wed, 30 Jul 2025 17:45:31 -0500 Subject: [PATCH 2/2] minimal changes to the feat demo page --- .../src/app/grant-change-demo/page.tsx | 480 +++++++++--------- apps/demo-app/src/app/layout.tsx | 13 +- 2 files changed, 260 insertions(+), 233 deletions(-) diff --git a/apps/demo-app/src/app/grant-change-demo/page.tsx b/apps/demo-app/src/app/grant-change-demo/page.tsx index 0c3d557a..744d2698 100644 --- a/apps/demo-app/src/app/grant-change-demo/page.tsx +++ b/apps/demo-app/src/app/grant-change-demo/page.tsx @@ -1,220 +1,246 @@ "use client"; import { useState, useEffect } from "react"; import { - useAbstraxionAccount, - useAbstraxionSigningClient, + useAbstraxionAccount, + useAbstraxionSigningClient, } from "@burnt-labs/abstraxion"; import { Button } from "@burnt-labs/ui"; import "@burnt-labs/ui/dist/index.css"; import Link from "next/link"; import { GrantChangeNotification } from "../../components/GrantChangeNotification"; +import { treasuryConfig } from "../layout"; export default function GrantChangeDemoPage(): JSX.Element { - const { - data: account, - login, - logout, - isConnecting, - grantsChanged, - } = useAbstraxionAccount(); - - const { client } = useAbstraxionSigningClient(); - const [notifications, setNotifications] = useState([]); - - // Monitor grant changes and log them - useEffect(() => { - if (grantsChanged) { - const timestamp = new Date().toLocaleTimeString(); - setNotifications((prev) => [ - ...prev, - `${timestamp}: Grant changes detected!`, - ]); - } - }, [grantsChanged]); - - const addNotification = (message: string) => { - const timestamp = new Date().toLocaleTimeString(); - setNotifications((prev) => [...prev, `${timestamp}: ${message}`]); - }; - - const clearNotifications = () => { - setNotifications([]); - }; - - const handleLogin = async () => { - try { - addNotification("Attempting to login..."); - await login(); - addNotification("Login successful!"); - } catch (error) { - addNotification(`Login failed: ${error}`); - } - }; - - const handleLogout = () => { - addNotification("Logging out..."); - logout(); - addNotification("Logged out successfully!"); - }; - - return ( -
-
-

- Grant Change Demo -

-

- This page demonstrates the auto logout feature when grant changes are - detected. -

-
- -
- {/* Connection Status */} -
-

- Connection Status -

- -
-
- Connected: - - {account.bech32Address ? "Yes" : "No"} - -
- -
- Grants Changed: - - {grantsChanged ? "Yes" : "No"} - -
- -
- Client: - - {client ? "Connected" : "Not connected"} - -
- - {account.bech32Address && ( -
-

Address:

-

- {account.bech32Address} -

-
- )} -
- -
- - - {account.bech32Address && ( - - )} -
-
- - {/* Feature Information */} -
-

- Feature Information -

- -
-
-

Auto Logout

-

- When enabled, users are automatically logged out if grant - changes are detected at startup. -

-
- -
-

Grant Monitoring

-

- The system checks for grant changes when the app starts, - comparing current grants with treasury configuration. -

-
- -
-

- Developer Control -

-

- Developers can access the `grantsChanged` property to implement - custom handling and user notifications. -

-
- -
-

Configuration

-

- Set `enableLogoutOnGrantChange: true` in your provider config to - enable automatic logout. -

-
-
-
-
- - {/* Activity Log */} -
-
-

Activity Log

- -
- -
- {notifications.length === 0 ? ( -

No activity yet...

- ) : ( -
- {notifications.map((notification, index) => ( -
- {notification} -
- ))} -
- )} -
-
- - {/* Code Example */} -
-

- Implementation Example -

- -
-
-            {`// Enable auto logout in provider config
+	const {
+		data: account,
+		login,
+		logout,
+		isConnecting,
+		isConnected,
+		grantsChanged,
+	} = useAbstraxionAccount();
+
+	const { client } = useAbstraxionSigningClient();
+	const [notifications, setNotifications] = useState([]);
+
+	// Monitor grant changes and log them
+	useEffect(() => {
+		if (grantsChanged) {
+			const timestamp = new Date().toLocaleTimeString();
+			setNotifications((prev) => [
+				...prev,
+				`${timestamp}: Grant changes detected!`,
+			]);
+		}
+	}, [grantsChanged]);
+
+	const addNotification = (message: string) => {
+		const timestamp = new Date().toLocaleTimeString();
+		setNotifications((prev) => [...prev, `${timestamp}: ${message}`]);
+	};
+
+	const clearNotifications = () => {
+		setNotifications([]);
+	};
+
+	const handleLogin = async () => {
+		try {
+			addNotification("Attempting to login...");
+			await login();
+			addNotification("Login successful!");
+		} catch (error) {
+			addNotification(`Login failed: ${error}`);
+		}
+	};
+
+	const handleLogout = () => {
+		addNotification("Logging out...");
+		logout();
+		addNotification("Logged out successfully!");
+	};
+
+	return (
+		
+
+

+ Grant Change Demo +

+

+ This page demonstrates the auto logout feature when grant changes are + detected. +

+
+ +
+ {/* Connection Status */} +
+

+ Connection Status +

+ +
+
+ Connected: + + {isConnected ? "Yes" : "No"} + +
+ +
+ autoLogoutOnGrantChange: + + {treasuryConfig.autoLogoutOnGrantChange ? "Yes" : "No"} + +
+ +
+ Grants Changed: + + {grantsChanged ? "Yes" : "No"} + +
+ +
+ Client: + + {client ? "Connected" : "Not connected"} + +
+ + {account.bech32Address && ( +
+

Wallet:

+

+ {account.bech32Address} +

+
+ )} + + {account.bech32Address && ( +
+

Treasury:

+

+ {treasuryConfig.treasury || "Not set"} +

+
+ )} +
+ +
+ {!isConnected && ( + + )} + + {isConnected && ( + + )} +
+
+ + {/* Feature Information */} +
+

+ Feature Information +

+ +
+
+

Auto Logout

+

+ When enabled, users are automatically logged out if grant + changes are detected at startup. +

+
+ +
+

Grant Monitoring

+

+ The system checks for grant changes when the app starts, + comparing current grants with treasury configuration. +

+
+ +
+

+ Developer Control +

+

+ Developers can access the `grantsChanged` property to implement + custom handling and user notifications. +

+
+ +
+

Configuration

+

+ Set `enableLogoutOnGrantChange: true` in your provider config to + enable automatic logout. +

+
+
+
+
+ + {/* Activity Log */} +
+
+

Activity Log

+ +
+ +
+ {notifications.length === 0 ? ( +

No activity yet...

+ ) : ( +
+ {notifications.map((notification, index) => ( +
+ {notification} +
+ ))} +
+ )} +
+
+ + {/* Code Example */} +
+

+ Implementation Example +

+ +
+
+						{`// Enable auto logout in provider config
 const config = {
   treasury: "xion1...",
   enableLogoutOnGrantChange: true
@@ -229,24 +255,24 @@ useEffect(() => {
     showNotification("Permissions updated. Please reconnect.");
   }
 }, [grantsChanged, isConnected]);`}
-          
-
-
- -
- - ← Back to examples - -
- - {/* Grant Change Notification */} - addNotification("Reconnected via notification")} - onDismiss={() => addNotification("Notification dismissed")} - /> -
- ); +
+
+
+ +
+ + ← Back to examples + +
+ + {/* Grant Change Notification */} + addNotification("Reconnected via notification")} + onDismiss={() => addNotification("Notification dismissed")} + /> +
+ ); } diff --git a/apps/demo-app/src/app/layout.tsx b/apps/demo-app/src/app/layout.tsx index c1b9da87..40a1444b 100644 --- a/apps/demo-app/src/app/layout.tsx +++ b/apps/demo-app/src/app/layout.tsx @@ -6,15 +6,16 @@ import { AbstraxionProvider } from "@burnt-labs/abstraxion"; const inter = Inter({ subsets: ["latin"] }); // Example XION seat contract -const seatContractAddress = +const currentTreasury = "xion12724lueegeee65l5ekdq5p2wtz7euevdl0vyxv7h75ls4pt0qkasvg7tca"; +// const curentTreasury = "xion1y0pks85yaxagmre5c5l8sr5unlt4874e5x9x4q3c0puqut9xmg9qnflwp8"; const legacyConfig = { contracts: [ // Usually, you would have a list of different contracts here - seatContractAddress, + currentTreasury, { - address: seatContractAddress, + address: currentTreasury, amounts: [{ denom: "uxion", amount: "1000000" }], }, ], @@ -30,10 +31,10 @@ const legacyConfig = { // restUrl: "https://api.xion-mainnet-1.burnt.com:443", }; -const treasuryConfig = { - treasury: "xion12724lueegeee65l5ekdq5p2wtz7euevdl0vyxv7h75ls4pt0qkasvg7tca", // Example XION treasury instance for instantiating smart contracts +export const treasuryConfig = { + treasury: currentTreasury, // Example XION treasury instance for instantiating smart contracts gasPrice: "0.001uxion", // If you feel the need to change the gasPrice when connecting to signer, set this value. Please stick to the string format seen in example - autoLogoutOnGrantChange: false, // Disable auto logout to allow custom handling + autoLogoutOnGrantChange: true, // Disable auto logout to allow custom handling // Optional params to activate mainnet config // rpcUrl: "https://rpc.xion-mainnet-1.burnt.com:443", // restUrl: "https://api.xion-mainnet-1.burnt.com:443",