-
+
- )
+ );
}
- // Route for wallet connection redirect
- if (currentPath === '/connect') {
- return
+ // Step 1: Not registered or wallet not connected - show onboarding (includes account creation)
+ if (!isRegistered || !signingClient) {
+ return
;
}
- // Main app routes
- return (
-
- {accountExists ? (
-
- ) : (
-
- )}
-
- )
+ // Step 2: Registered user with connected wallet - show main dashboard
+ return
;
}
-export default App
+export default App;
diff --git a/frontend/src/components/AccountCreation.tsx b/frontend/src/components/AccountCreation.tsx
index bab8bd8..bf40b34 100644
--- a/frontend/src/components/AccountCreation.tsx
+++ b/frontend/src/components/AccountCreation.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import WebApp from "@twa-dev/sdk";
import { generateKeyPair, importKeyPair } from "../services/crypto";
import type { KeyPair } from "../services/crypto";
-import { saveAccount } from "../services/storage";
+import { saveAccount, saveMnemonic, saveWalletType } from "../services/storage";
import {
showAlert,
showConfirm,
@@ -171,6 +171,7 @@ export function AccountCreation({ onAccountCreated }: AccountCreationProps) {
hapticNotification("success");
await new Promise((resolve) => setTimeout(resolve, 500));
+ saveWalletType('walletconnect');
saveAccount({
address: account.address,
publicKey: account.publicKey,
@@ -269,6 +270,7 @@ export function AccountCreation({ onAccountCreated }: AccountCreationProps) {
localStorage.removeItem("wc_connecting_state");
hapticNotification("success");
await new Promise((resolve) => setTimeout(resolve, 500));
+ saveWalletType('walletconnect');
saveAccount({
address: account.address,
publicKey: account.publicKey,
@@ -336,12 +338,23 @@ export function AccountCreation({ onAccountCreated }: AccountCreationProps) {
hapticImpact("medium");
try {
const importedKeyPair = await importKeyPair(trimmedMnemonic);
- hapticNotification("success");
- saveAccount({
- address: importedKeyPair.address,
- publicKey: importedKeyPair.publicKey,
- });
- onAccountCreated();
+
+ showConfirm(
+ "Save mnemonic to device for easy signing? (Less secure but more convenient)\n\nIf NO, you'll need to enter it each time you sign.",
+ (saveMnemonicToDevice) => {
+ if (saveMnemonicToDevice) {
+ // Save mnemonic to localStorage
+ saveMnemonic(trimmedMnemonic);
+ }
+ saveWalletType('local');
+ hapticNotification("success");
+ saveAccount({
+ address: importedKeyPair.address,
+ publicKey: importedKeyPair.publicKey,
+ });
+ onAccountCreated();
+ }
+ );
} catch (err) {
console.error("Failed to import mnemonic:", err);
hapticNotification("error");
@@ -379,6 +392,7 @@ export function AccountCreation({ onAccountCreated }: AccountCreationProps) {
console.log("✅ Wallet approved! Account:", walletAccount);
hapticNotification("success");
+ saveWalletType('walletconnect');
saveAccount({
address: walletAccount.address,
publicKey: walletAccount.publicKey,
@@ -404,9 +418,12 @@ export function AccountCreation({ onAccountCreated }: AccountCreationProps) {
if (!keyPair) return;
showConfirm(
- "Have you saved your mnemonic? It will not be shown again!",
- (confirmed) => {
- if (confirmed) {
+ "Save mnemonic to device for easy signing? (Less secure but more convenient)\n\nIf NO, you'll need to enter it each time you sign.",
+ (saveMnemonicToDevice) => {
+ if (saveMnemonicToDevice) {
+ // Save mnemonic to localStorage
+ saveMnemonic(keyPair.mnemonic);
+ saveWalletType('local');
hapticNotification("success");
saveAccount({
address: keyPair.address,
@@ -414,7 +431,14 @@ export function AccountCreation({ onAccountCreated }: AccountCreationProps) {
});
onAccountCreated();
} else {
- hapticNotification("warning");
+ // Don't save mnemonic, only save address and public key
+ saveWalletType('local');
+ hapticNotification("success");
+ saveAccount({
+ address: keyPair.address,
+ publicKey: keyPair.publicKey,
+ });
+ onAccountCreated();
}
}
);
diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx
index a960729..03924d4 100644
--- a/frontend/src/components/Dashboard.tsx
+++ b/frontend/src/components/Dashboard.tsx
@@ -10,6 +10,10 @@ import {
hapticNotification,
} from "../utils/telegram";
import "./Dashboard.css";
+import { useSigningClient } from "../hooks/useSigningClient";
+import { TgContractPaymentsClient } from "../contracts/TgContractPayments.client";
+import { WalletManager } from "./WalletManager";
+import { getContractAddress, setContractAddress } from "../config/contracts";
interface DashboardProps {
onLogout: () => void;
@@ -31,6 +35,19 @@ export function Dashboard({ onLogout }: DashboardProps) {
const [copied, setCopied] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [telegramUser, setTelegramUser] = useState
(null);
+ const [contractClient, setContractClient] =
+ useState(null);
+ const [contractAddress, setContractAddressState] = useState("");
+ const [showContractSetup, setShowContractSetup] = useState(false);
+ const [contractAddressInput, setContractAddressInput] = useState("");
+
+ // Use the signing client hook
+ const {
+ client: signingClient,
+ address: signerAddress,
+ walletType,
+ isReady: isSignerReady,
+ } = useSigningClient();
useEffect(() => {
const storedAccount = getStoredAccount();
@@ -45,6 +62,15 @@ export function Dashboard({ onLogout }: DashboardProps) {
fetchBalance(storedAccount.address);
}
+ // Try to load contract address
+ try {
+ const addr = getContractAddress("payments");
+ setContractAddressState(addr);
+ } catch (err) {
+ // Contract address not configured yet
+ console.log("Contract address not configured:", err);
+ }
+
// Setup Settings Button
WebApp.SettingsButton.show();
WebApp.SettingsButton.onClick(handleSettingsClick);
@@ -55,6 +81,30 @@ export function Dashboard({ onLogout }: DashboardProps) {
};
}, []);
+ // Initialize contract client when signing client is ready
+ useEffect(() => {
+ if (
+ signingClient &&
+ signerAddress &&
+ contractAddress &&
+ contractAddress !== "neutron1..."
+ ) {
+ try {
+ const client = new TgContractPaymentsClient(
+ signingClient,
+ signerAddress,
+ contractAddress
+ );
+ setContractClient(client);
+ console.log("✅ Contract client initialized:", contractAddress);
+ } catch (err) {
+ console.error("Failed to initialize contract client:", err);
+ }
+ } else {
+ setContractClient(null);
+ }
+ }, [signingClient, signerAddress, contractAddress]);
+
const fetchBalance = async (address: string) => {
setLoadingBalance(true);
try {
@@ -106,6 +156,25 @@ export function Dashboard({ onLogout }: DashboardProps) {
}
};
+ const handleSetContractAddress = () => {
+ if (!contractAddressInput.trim()) {
+ showAlert("Please enter a contract address");
+ return;
+ }
+
+ if (!contractAddressInput.startsWith("neutron1")) {
+ showAlert('Contract address must start with "neutron1"');
+ return;
+ }
+
+ setContractAddress("payments", contractAddressInput.trim());
+ setContractAddressState(contractAddressInput.trim());
+ setShowContractSetup(false);
+ setContractAddressInput("");
+ hapticNotification("success");
+ showAlert("Contract address saved!");
+ };
+
if (!account) {
return Loading...
;
}
@@ -113,7 +182,132 @@ export function Dashboard({ onLogout }: DashboardProps) {
return (
-
Neutron Wallet
+
Telegram Payments
+
+ {/* Wallet Manager - shows initialization UI if needed */}
+
+
+ {/* Signer Status */}
+ {isSignerReady && signingClient && (
+
+
+ ✅ Signer Ready ({walletType})
+
+
+ )}
+
+ {/* Contract Status */}
+
+
+ {contractClient
+ ? "✅ Contract Client Ready"
+ : "⚠️ Contract Not Configured"}
+
+ {contractAddress && contractAddress !== "neutron1..." ? (
+
+ {contractAddress}
+
+ ) : (
+
+ )}
+
+
+ {/* Contract Setup */}
+ {showContractSetup && (
+
+
+ Configure Contract Address
+
+ setContractAddressInput(e.target.value)}
+ placeholder="neutron1..."
+ style={{
+ width: "100%",
+ padding: "8px",
+ borderRadius: "6px",
+ border: "1px solid var(--tg-theme-hint-color, #ccc)",
+ fontSize: "12px",
+ fontFamily: "monospace",
+ marginBottom: "8px",
+ }}
+ />
+
+
+ )}
{telegramUser && (
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..52c2ddc
--- /dev/null
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -0,0 +1,98 @@
+import { Component, type ReactNode } from "react";
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+ error?: Error;
+}
+
+export class ErrorBoundary extends Component
{
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: any) {
+ console.error("ErrorBoundary caught an error:", error, errorInfo);
+
+ // Try to display error in Telegram if available
+ try {
+ if (typeof window !== "undefined" && (window as any).Telegram?.WebApp) {
+ const WebApp = (window as any).Telegram.WebApp;
+ if (WebApp.showAlert) {
+ WebApp.showAlert(
+ `Error: ${error.message}\n\nCheck console for details`
+ );
+ }
+ }
+ } catch (e) {
+ console.error("Failed to show error in Telegram:", e);
+ }
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+ Something went wrong
+
+
+ {this.state.error?.message}
+
+
+ {this.state.error?.stack}
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/frontend/src/components/Onboarding.css b/frontend/src/components/Onboarding.css
new file mode 100644
index 0000000..c291405
--- /dev/null
+++ b/frontend/src/components/Onboarding.css
@@ -0,0 +1,251 @@
+.onboarding {
+ min-height: 100vh;
+ padding: 20px;
+ background: var(--tg-theme-bg-color, #fff);
+}
+
+.onboarding-progress {
+ margin-bottom: 32px;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 4px;
+ background: var(--tg-theme-secondary-bg-color, #f0f0f0);
+ border-radius: 2px;
+ overflow: hidden;
+ margin-bottom: 8px;
+}
+
+.progress-fill {
+ height: 100%;
+ background: var(--tg-theme-button-color, #3390ec);
+ transition: width 0.3s ease;
+}
+
+.progress-text {
+ font-size: 12px;
+ color: var(--tg-theme-hint-color, #999);
+ text-align: center;
+}
+
+.onboarding-content {
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+.onboarding-step {
+ animation: slideIn 0.3s ease;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.step-icon {
+ font-size: 64px;
+ text-align: center;
+ margin-bottom: 24px;
+}
+
+.onboarding-step h1,
+.onboarding-step h2 {
+ margin: 0 0 16px 0;
+ color: var(--tg-theme-text-color, #000);
+ text-align: center;
+}
+
+.onboarding-step h1 {
+ font-size: 28px;
+}
+
+.onboarding-step h2 {
+ font-size: 24px;
+}
+
+.intro-text,
+.step-description {
+ text-align: center;
+ color: var(--tg-theme-hint-color, #666);
+ margin-bottom: 32px;
+ line-height: 1.5;
+}
+
+.feature-list {
+ margin: 32px 0;
+}
+
+.feature {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 24px;
+ padding: 16px;
+ background: var(--tg-theme-secondary-bg-color, #f8f8f8);
+ border-radius: 12px;
+}
+
+.feature-icon {
+ font-size: 32px;
+ flex-shrink: 0;
+}
+
+.feature h3 {
+ margin: 0 0 4px 0;
+ font-size: 16px;
+ color: var(--tg-theme-text-color, #000);
+}
+
+.feature p {
+ margin: 0;
+ font-size: 14px;
+ color: var(--tg-theme-hint-color, #666);
+}
+
+.primary-button {
+ width: 100%;
+ padding: 16px;
+ background: var(--tg-theme-button-color, #3390ec);
+ color: var(--tg-theme-button-text-color, #fff);
+ border: none;
+ border-radius: 12px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ margin-top: 24px;
+}
+
+.primary-button:hover {
+ opacity: 0.9;
+}
+
+.primary-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.limit-input-container {
+ margin: 24px 0;
+}
+
+.limit-input-container label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 600;
+ color: var(--tg-theme-text-color, #000);
+}
+
+.input-with-suffix {
+ position: relative;
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.input-with-suffix input {
+ width: 100%;
+ padding: 16px;
+ padding-right: 70px;
+ border: 1px solid var(--tg-theme-hint-color, #ccc);
+ border-radius: 12px;
+ font-size: 24px;
+ font-weight: 600;
+ text-align: right;
+ background: var(--tg-theme-bg-color, #fff);
+ color: var(--tg-theme-text-color, #000);
+}
+
+.input-suffix {
+ position: absolute;
+ right: 16px;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--tg-theme-hint-color, #999);
+}
+
+.limit-presets {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.limit-presets button {
+ padding: 12px;
+ background: var(--tg-theme-secondary-bg-color, #f0f0f0);
+ color: var(--tg-theme-text-color, #000);
+ border: 1px solid transparent;
+ border-radius: 8px;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.limit-presets button:hover {
+ border-color: var(--tg-theme-button-color, #3390ec);
+ background: var(--tg-theme-bg-color, #fff);
+}
+
+.helper-text {
+ font-size: 12px;
+ color: var(--tg-theme-hint-color, #999);
+ text-align: center;
+ margin: 0;
+}
+
+.registration-summary {
+ background: var(--tg-theme-secondary-bg-color, #f8f8f8);
+ border-radius: 12px;
+ padding: 20px;
+ margin: 24px 0;
+}
+
+.summary-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--tg-theme-hint-color, rgba(0, 0, 0, 0.1));
+}
+
+.summary-item:last-child {
+ border-bottom: none;
+}
+
+.summary-item .label {
+ font-size: 14px;
+ color: var(--tg-theme-hint-color, #666);
+}
+
+.summary-item .value {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--tg-theme-text-color, #000);
+}
+
+.summary-item .value.mono {
+ font-family: monospace;
+}
+
+.info-box {
+ background: rgba(51, 144, 236, 0.1);
+ border: 1px solid rgba(51, 144, 236, 0.3);
+ border-radius: 12px;
+ padding: 16px;
+ margin: 24px 0;
+}
+
+.info-box p {
+ margin: 0;
+ font-size: 14px;
+ color: var(--tg-theme-text-color, #000);
+ line-height: 1.5;
+}
diff --git a/frontend/src/components/Onboarding.tsx b/frontend/src/components/Onboarding.tsx
new file mode 100644
index 0000000..1e74595
--- /dev/null
+++ b/frontend/src/components/Onboarding.tsx
@@ -0,0 +1,380 @@
+import { useState, useEffect } from 'react';
+import WebApp from '@twa-dev/sdk';
+import { hapticImpact, hapticNotification, showAlert, showConfirm } from '../utils/telegram';
+import { WalletManager } from './WalletManager';
+import { AccountCreation } from './AccountCreation';
+import { useSigningClient } from '../hooks/useSigningClient';
+import type { StoredAccount } from '../services/storage';
+import { getStoredAccount } from '../services/storage';
+import { registerWithAuthz } from '../services/authz';
+import { getContractAddress } from '../config/contracts';
+import { log } from '../debug';
+import './Onboarding.css';
+
+interface OnboardingProps {
+ onComplete: () => void;
+}
+
+type OnboardingStep = 'welcome' | 'create-account' | 'wallet-connect' | 'set-limit' | 'register';
+
+export function Onboarding({ onComplete }: OnboardingProps) {
+ const [account, setAccount] = useState(null);
+ const [currentStep, setCurrentStep] = useState('welcome');
+ const [spendingLimit, setSpendingLimit] = useState('100'); // Default 100 NTRN
+ const [isRegistering, setIsRegistering] = useState(false);
+ const [isResetting, setIsResetting] = useState(false);
+ const { client: signingClient, address: signerAddress } = useSigningClient();
+
+ useEffect(() => {
+ WebApp.BackButton.hide();
+
+ // Check if account already exists
+ const storedAccount = getStoredAccount();
+ setAccount(storedAccount);
+
+ // If wallet is already connected and registered, skip to dashboard
+ if (signingClient && signerAddress && storedAccount) {
+ const isRegistered = localStorage.getItem('telegram_payments_registered') === 'true';
+ if (isRegistered) {
+ onComplete();
+ }
+ }
+ }, [signingClient, signerAddress, onComplete]);
+
+ // Auto-proceed from wallet-connect to set-limit once wallet is ready
+ useEffect(() => {
+ if (currentStep === 'wallet-connect' && signingClient && signerAddress && account && !isResetting) {
+ log('Wallet is ready, auto-proceeding to set-limit step');
+ log(`Signing client: ${!!signingClient}, Address: ${signerAddress}, Account: ${account.address}`);
+
+ // Verify address matches account
+ if (signerAddress === account.address) {
+ // Longer delay to ensure wallet is fully settled
+ setTimeout(() => {
+ setCurrentStep('set-limit');
+ }, 1500);
+ } else {
+ log(`Address mismatch: ${signerAddress} vs ${account.address}`, 'warn');
+ }
+ }
+ }, [currentStep, signingClient, signerAddress, account, isResetting]);
+
+ const handleNext = () => {
+ hapticImpact('light');
+
+ switch (currentStep) {
+ case 'welcome':
+ // Check if account exists
+ if (!account) {
+ setCurrentStep('create-account');
+ } else {
+ setCurrentStep('wallet-connect');
+ }
+ break;
+ case 'create-account':
+ // Account should be created by AccountCreation component
+ if (!account) {
+ showAlert('Please create or import an account first');
+ return;
+ }
+ setCurrentStep('wallet-connect');
+ break;
+ case 'wallet-connect':
+ if (!signingClient) {
+ showAlert('Please initialize your wallet first');
+ return;
+ }
+ setCurrentStep('set-limit');
+ break;
+ case 'set-limit':
+ if (!spendingLimit || parseFloat(spendingLimit) <= 0) {
+ showAlert('Please enter a valid spending limit');
+ return;
+ }
+ setCurrentStep('register');
+ break;
+ }
+ };
+
+ const handleAccountCreated = async () => {
+ const newAccount = getStoredAccount();
+ setAccount(newAccount);
+
+ // Clear resetting flag if it was set
+ setIsResetting(false);
+
+ setCurrentStep('wallet-connect');
+
+ // Trigger wallet reinitialization by calling reinitialize from the hook
+ // This ensures the signing client picks up the newly created wallet
+ log(`Account created: ${newAccount?.address}`);
+ log('Moving to wallet-connect step...');
+ };
+
+ const handleResetWallet = () => {
+ showConfirm(
+ 'Are you sure you want to connect a different wallet? Your current wallet settings will be cleared.',
+ (confirmed) => {
+ if (confirmed) {
+ hapticImpact('medium');
+ log('Resetting wallet settings...');
+
+ setIsResetting(true);
+
+ // Clear all stored data
+ localStorage.removeItem('telegram_payments_account');
+ localStorage.removeItem('telegram_payments_mnemonic');
+ localStorage.removeItem('telegram_payments_wallet_type');
+ localStorage.removeItem('telegram_payments_registered');
+
+ // Reset state
+ setAccount(null);
+
+ // Go back to account creation step
+ setCurrentStep('create-account');
+
+ // Allow auto-proceed again after reset is complete
+ setTimeout(() => {
+ setIsResetting(false);
+ log('Wallet settings cleared. Ready to connect new wallet.');
+ }, 500);
+ }
+ }
+ );
+ };
+
+ const handleRegister = async () => {
+ if (!signingClient || !signerAddress) {
+ showAlert('Wallet not initialized');
+ return;
+ }
+
+ // Get Telegram user info
+ const user = WebApp.initDataUnsafe.user;
+ if (!user) {
+ showAlert('Could not get Telegram user info');
+ return;
+ }
+
+ const tgHandle = user.username || `user_${user.id}`;
+
+ // Get contract address
+ let contractAddress: string;
+ try {
+ contractAddress = getContractAddress('payments');
+ } catch (err) {
+ showAlert('Contract address not configured. Please set it in settings.');
+ return;
+ }
+
+ setIsRegistering(true);
+ hapticImpact('medium');
+
+ try {
+ log(`Starting registration for @${tgHandle}...`);
+
+ // Register user and create authz grant in one transaction
+ const result = await registerWithAuthz(signingClient, {
+ userAddress: signerAddress,
+ contractAddress,
+ tgHandle,
+ spendLimit: spendingLimit,
+ });
+
+ log(`✅ Registration complete! Tx: ${result.transactionHash}`);
+ hapticNotification('success');
+
+ // Wait a bit for user to see success
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ onComplete();
+ } catch (err) {
+ console.error('Registration failed:', err);
+ log(`❌ Registration failed: ${(err as Error).message}`, 'error');
+ hapticNotification('error');
+ showAlert(`Registration failed: ${(err as Error).message}`);
+ } finally {
+ setIsRegistering(false);
+ }
+ };
+
+ const getStepNumber = () => {
+ const steps: Record = {
+ welcome: 1,
+ 'create-account': 2,
+ 'wallet-connect': 3,
+ 'set-limit': 4,
+ register: 5,
+ };
+ return steps[currentStep];
+ };
+
+ return (
+
+ {/* Progress indicator */}
+
+
+
Step {getStepNumber()} of 5
+
+
+ {/* Step content */}
+
+ {currentStep === 'welcome' && (
+
+
💸
+
Welcome to Telegram Payments
+
+ Send and receive cryptocurrency payments directly through Telegram, powered by the Cosmos ecosystem.
+
+
+
+
⚡
+
+
Instant Payments
+
Send crypto to any Telegram user instantly
+
+
+
+
🔒
+
+
Secure & Non-Custodial
+
Your keys, your crypto. We never hold your funds
+
+
+
+
🎯
+
+
Spending Limits
+
Set custom limits for peace of mind
+
+
+
+
+
+ )}
+
+ {currentStep === 'create-account' && (
+
+ )}
+
+ {currentStep === 'wallet-connect' && (
+
+
🔐
+
Connect Your Wallet
+
+ Initialize your wallet to start sending and receiving payments.
+
+
+
+
+ {signingClient && signerAddress && (
+
+
+ ✅ Wallet connected: {signerAddress.substring(0, 12)}...
+
+
+
+ )}
+
+ )}
+
+ {currentStep === 'set-limit' && (
+
+
💰
+
Set Spending Limit
+
+ Set a maximum amount that can be spent through Telegram in a single transaction. You can change this anytime.
+
+
+
+
+
+ setSpendingLimit(e.target.value)}
+ placeholder="100"
+ />
+ NTRN
+
+
+
+
+
+
+
+
+ ≈ ${(parseFloat(spendingLimit || '0') * 0.5).toFixed(2)} USD (estimated)
+
+
+
+
+
+
+
+ )}
+
+ {currentStep === 'register' && (
+
+
✅
+
Complete Setup
+
+ Register your Telegram handle with the blockchain to start receiving payments.
+
+
+
+
+ Telegram Handle:
+ @{WebApp.initDataUnsafe.user?.username || `user_${WebApp.initDataUnsafe.user?.id}`}
+
+
+ Wallet Address:
+ {account?.address.substring(0, 20)}...
+
+
+ Spending Limit:
+ {spendingLimit} NTRN
+
+
+
+
+
+ This will create an authorization that allows the contract to spend up to {spendingLimit} NTRN on your behalf for Telegram payments.
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/RegisteredDashboard.css b/frontend/src/components/RegisteredDashboard.css
new file mode 100644
index 0000000..0d18964
--- /dev/null
+++ b/frontend/src/components/RegisteredDashboard.css
@@ -0,0 +1,314 @@
+.registered-dashboard {
+ min-height: 100vh;
+ padding: 20px;
+ background: var(--tg-theme-bg-color, #f5f5f5);
+}
+
+.dashboard-header {
+ text-align: center;
+ margin-bottom: 24px;
+}
+
+.dashboard-header h1 {
+ margin: 0 0 8px 0;
+ font-size: 24px;
+ color: var(--tg-theme-text-color, #000);
+}
+
+.user-badge {
+ display: inline-block;
+ padding: 6px 12px;
+ background: var(--tg-theme-button-color, #3390ec);
+ color: var(--tg-theme-button-text-color, #fff);
+ border-radius: 16px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.balance-card {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 16px;
+ padding: 24px;
+ margin-bottom: 20px;
+ color: white;
+ text-align: center;
+}
+
+.balance-label {
+ font-size: 14px;
+ opacity: 0.9;
+ margin-bottom: 8px;
+}
+
+.balance-amount {
+ font-size: 36px;
+ font-weight: 700;
+ margin-bottom: 16px;
+}
+
+.balance-amount .loading {
+ font-size: 18px;
+ opacity: 0.8;
+}
+
+.refresh-button {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ padding: 8px 16px;
+ border-radius: 20px;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.refresh-button:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.refresh-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.quick-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.action-card {
+ background: var(--tg-theme-bg-color, #fff);
+ border: 1px solid var(--tg-theme-hint-color, rgba(0, 0, 0, 0.1));
+ border-radius: 12px;
+ padding: 20px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.action-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.action-icon {
+ font-size: 32px;
+ margin-bottom: 8px;
+}
+
+.action-label {
+ font-size: 12px;
+ color: var(--tg-theme-hint-color, #666);
+ margin-bottom: 4px;
+}
+
+.action-value {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--tg-theme-text-color, #000);
+}
+
+.info-section {
+ background: var(--tg-theme-bg-color, #fff);
+ border-radius: 12px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.info-section h3 {
+ margin: 0 0 16px 0;
+ font-size: 16px;
+ color: var(--tg-theme-text-color, #000);
+}
+
+.info-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--tg-theme-hint-color, rgba(0, 0, 0, 0.1));
+}
+
+.info-item:last-child {
+ border-bottom: none;
+}
+
+.info-label {
+ font-size: 14px;
+ color: var(--tg-theme-hint-color, #666);
+}
+
+.info-value {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--tg-theme-text-color, #000);
+}
+
+.info-value.mono {
+ font-family: monospace;
+ font-size: 12px;
+}
+
+/* Modal Styles */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 20px;
+}
+
+.modal-content {
+ background: var(--tg-theme-bg-color, #fff);
+ border-radius: 16px;
+ padding: 24px;
+ max-width: 400px;
+ width: 100%;
+ max-height: 80vh;
+ overflow-y: auto;
+}
+
+.modal-content h2 {
+ margin: 0 0 8px 0;
+ font-size: 20px;
+ color: var(--tg-theme-text-color, #000);
+}
+
+.modal-description {
+ margin: 0 0 20px 0;
+ font-size: 14px;
+ color: var(--tg-theme-hint-color, #666);
+ line-height: 1.5;
+}
+
+.limit-input-group {
+ position: relative;
+ margin-bottom: 16px;
+}
+
+.limit-input-group input {
+ width: 100%;
+ padding: 16px;
+ padding-right: 70px;
+ border: 1px solid var(--tg-theme-hint-color, #ccc);
+ border-radius: 12px;
+ font-size: 24px;
+ font-weight: 600;
+ text-align: right;
+}
+
+.limit-input-group .input-suffix {
+ position: absolute;
+ right: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--tg-theme-hint-color, #999);
+}
+
+.limit-presets {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.limit-presets button {
+ padding: 12px;
+ background: var(--tg-theme-secondary-bg-color, #f0f0f0);
+ border: 1px solid transparent;
+ border-radius: 8px;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.limit-presets button:hover {
+ border-color: var(--tg-theme-button-color, #3390ec);
+}
+
+.modal-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.primary-button,
+.secondary-button {
+ padding: 12px;
+ border: none;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+}
+
+.primary-button {
+ background: var(--tg-theme-button-color, #3390ec);
+ color: var(--tg-theme-button-text-color, #fff);
+}
+
+.secondary-button {
+ background: var(--tg-theme-secondary-bg-color, #f0f0f0);
+ color: var(--tg-theme-text-color, #000);
+}
+
+.primary-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Settings Menu */
+.settings-menu {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.menu-item {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px;
+ background: var(--tg-theme-secondary-bg-color, #f5f5f5);
+ border: none;
+ border-radius: 12px;
+ cursor: pointer;
+ text-align: left;
+ transition: all 0.2s;
+}
+
+.menu-item:hover {
+ transform: translateX(4px);
+}
+
+.menu-item span {
+ font-size: 24px;
+ flex-shrink: 0;
+}
+
+.menu-item-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--tg-theme-text-color, #000);
+ margin-bottom: 4px;
+}
+
+.menu-item-desc {
+ font-size: 12px;
+ color: var(--tg-theme-hint-color, #666);
+}
+
+.menu-item.danger .menu-item-title {
+ color: #dc3545;
+}
diff --git a/frontend/src/components/RegisteredDashboard.tsx b/frontend/src/components/RegisteredDashboard.tsx
new file mode 100644
index 0000000..8d1c0a2
--- /dev/null
+++ b/frontend/src/components/RegisteredDashboard.tsx
@@ -0,0 +1,329 @@
+import { useState, useEffect } from 'react';
+import WebApp from '@twa-dev/sdk';
+import { getStoredAccount, clearAccount } from '../services/storage';
+import type { StoredAccount } from '../services/storage';
+import { getNeutronBalance, type NeutronBalance } from '../services/neutron';
+import {
+ showAlert,
+ showConfirm,
+ hapticImpact,
+ hapticNotification,
+} from '../utils/telegram';
+import { useSigningClient } from '../hooks/useSigningClient';
+import { updateAuthzLimit, revokeAuthz } from '../services/authz';
+import { getContractAddress } from '../config/contracts';
+import './RegisteredDashboard.css';
+
+interface RegisteredDashboardProps {
+ onLogout: () => void;
+}
+
+export function RegisteredDashboard({ onLogout }: RegisteredDashboardProps) {
+ const [account, setAccount] = useState(null);
+ const [balance, setBalance] = useState(null);
+ const [loadingBalance, setLoadingBalance] = useState(true);
+ const [copied, setCopied] = useState(false);
+ const [showSettings, setShowSettings] = useState(false);
+ const [telegramUser, setTelegramUser] = useState(null);
+ const [showLimitEditor, setShowLimitEditor] = useState(false);
+ const [newLimit, setNewLimit] = useState('100');
+ const [isUpdatingLimit, setIsUpdatingLimit] = useState(false);
+
+ const { client: signingClient, address: signerAddress } = useSigningClient();
+
+ useEffect(() => {
+ const storedAccount = getStoredAccount();
+ setAccount(storedAccount);
+
+ // Get Telegram user info
+ const user = WebApp.initDataUnsafe.user;
+ setTelegramUser(user ?? null);
+
+ // Fetch balance
+ if (storedAccount) {
+ fetchBalance(storedAccount.address);
+ }
+
+ // Setup Settings Button
+ WebApp.SettingsButton.show();
+ WebApp.SettingsButton.onClick(() => {
+ hapticImpact('light');
+ setShowSettings(true);
+ });
+
+ return () => {
+ WebApp.SettingsButton.hide();
+ };
+ }, []);
+
+ const fetchBalance = async (address: string) => {
+ setLoadingBalance(true);
+ try {
+ const neutronBalance = await getNeutronBalance(address);
+ setBalance(neutronBalance);
+ } finally {
+ setLoadingBalance(false);
+ }
+ };
+
+ const copyAddress = async () => {
+ if (!account) return;
+ try {
+ await navigator.clipboard.writeText(account.address);
+ hapticNotification('success');
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ hapticNotification('error');
+ showAlert('Failed to copy to clipboard');
+ }
+ };
+
+ const handleUpdateLimit = async () => {
+ if (!signingClient || !signerAddress) {
+ showAlert('Wallet not initialized');
+ return;
+ }
+
+ if (!newLimit || parseFloat(newLimit) <= 0) {
+ showAlert('Please enter a valid limit');
+ return;
+ }
+
+ let contractAddress: string;
+ try {
+ contractAddress = getContractAddress('payments');
+ } catch (err) {
+ showAlert('Contract address not configured');
+ return;
+ }
+
+ setIsUpdatingLimit(true);
+ hapticImpact('medium');
+
+ try {
+ await updateAuthzLimit(signingClient, {
+ userAddress: signerAddress,
+ contractAddress,
+ newLimit,
+ });
+
+ hapticNotification('success');
+ showAlert(`Spending limit updated to ${newLimit} NTRN`);
+ setShowLimitEditor(false);
+ } catch (err) {
+ console.error('Failed to update limit:', err);
+ hapticNotification('error');
+ showAlert(`Failed to update limit: ${(err as Error).message}`);
+ } finally {
+ setIsUpdatingLimit(false);
+ }
+ };
+
+ const handleRevokeAccess = () => {
+ showConfirm(
+ 'Are you sure you want to revoke authorization? You will need to re-register to send payments via Telegram.',
+ async (confirmed) => {
+ if (!confirmed) return;
+
+ if (!signingClient || !signerAddress) {
+ showAlert('Wallet not initialized');
+ return;
+ }
+
+ let contractAddress: string;
+ try {
+ contractAddress = getContractAddress('payments');
+ } catch (err) {
+ showAlert('Contract address not configured');
+ return;
+ }
+
+ hapticImpact('medium');
+
+ try {
+ await revokeAuthz(signingClient, {
+ userAddress: signerAddress,
+ contractAddress,
+ });
+
+ hapticNotification('success');
+ showAlert('Authorization revoked successfully');
+ } catch (err) {
+ console.error('Failed to revoke:', err);
+ hapticNotification('error');
+ showAlert(`Failed to revoke: ${(err as Error).message}`);
+ }
+ }
+ );
+ };
+
+ const handleLogout = () => {
+ showConfirm(
+ 'Are you sure you want to remove this account? Make sure you have your recovery phrase saved!',
+ (confirmed) => {
+ if (confirmed) {
+ hapticNotification('success');
+ clearAccount();
+ onLogout();
+ }
+ }
+ );
+ };
+
+ if (!account) {
+ return Loading...
;
+ }
+
+ return (
+
+ {/* Header */}
+
+
💸 Telegram Payments
+ {telegramUser && (
+
+ @{telegramUser.username || `user_${telegramUser.id}`}
+
+ )}
+
+
+ {/* Balance Card */}
+
+
Total Balance
+
+ {loadingBalance ? (
+ Loading...
+ ) : (
+ {balance?.formatted || '0 NTRN'}
+ )}
+
+
+
+
+ {/* Quick Actions */}
+
+
+
+
+
+
+ {/* Account Info */}
+
+
Account Details
+
+ Telegram Handle
+
+ @{telegramUser?.username || `user_${telegramUser?.id}`}
+
+
+
+ Wallet Address
+
+ {account.address.substring(0, 12)}...
+ {account.address.substring(account.address.length - 8)}
+
+
+
+
+ {/* Limit Editor Modal */}
+ {showLimitEditor && (
+
setShowLimitEditor(false)}>
+
e.stopPropagation()}>
+
Update Spending Limit
+
+ Set the maximum amount that can be spent through Telegram in a single transaction.
+
+
+
+ setNewLimit(e.target.value)}
+ placeholder="100"
+ />
+ NTRN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Settings Modal */}
+ {showSettings && (
+
setShowSettings(false)}>
+
e.stopPropagation()}>
+
Settings
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/WalletManager.tsx b/frontend/src/components/WalletManager.tsx
new file mode 100644
index 0000000..9cfdd41
--- /dev/null
+++ b/frontend/src/components/WalletManager.tsx
@@ -0,0 +1,248 @@
+import { useState } from 'react';
+import { useSigningClient } from '../hooks/useSigningClient';
+import { showAlert, hapticNotification } from '../utils/telegram';
+import { hasMnemonic } from '../services/storage';
+
+interface WalletManagerProps {
+ onClientReady?: (client: any, address: string) => void;
+}
+
+/**
+ * Component to manage wallet initialization
+ * Use this in your Dashboard to get a signing client
+ */
+export function WalletManager({ onClientReady }: WalletManagerProps) {
+ const { client, address, walletType, isReady, error, initializeWithMnemonic, initializeWithKeplr, initializeWithWalletConnect } = useSigningClient();
+ const [isInitializing, setIsInitializing] = useState(false);
+ const [mnemonicInput, setMnemonicInput] = useState('');
+
+ // Debug logging
+ console.log('WalletManager state:', { client: !!client, address, walletType, isReady, error: error?.message });
+
+ // Notify parent when client is ready
+ if (client && address && onClientReady) {
+ onClientReady(client, address);
+ }
+
+ const handleMnemonicSubmit = async () => {
+ if (!mnemonicInput.trim()) {
+ showAlert('Please enter your mnemonic');
+ return;
+ }
+
+ setIsInitializing(true);
+ try {
+ await initializeWithMnemonic(mnemonicInput.trim());
+ hapticNotification('success');
+ setMnemonicInput(''); // Clear input
+ } catch (err) {
+ hapticNotification('error');
+ showAlert('Failed to initialize wallet: ' + (err as Error).message);
+ } finally {
+ setIsInitializing(false);
+ }
+ };
+
+ const handleKeplrConnect = async () => {
+ setIsInitializing(true);
+ try {
+ await initializeWithKeplr();
+ hapticNotification('success');
+ } catch (err) {
+ hapticNotification('error');
+ showAlert('Failed to connect to Keplr: ' + (err as Error).message);
+ } finally {
+ setIsInitializing(false);
+ }
+ };
+
+ const handleWalletConnectConnect = async () => {
+ setIsInitializing(true);
+ try {
+ await initializeWithWalletConnect();
+ hapticNotification('success');
+ } catch (err) {
+ hapticNotification('error');
+ showAlert('Failed to connect with WalletConnect: ' + (err as Error).message);
+ } finally {
+ setIsInitializing(false);
+ }
+ };
+
+ // If client is ready, don't show anything (or show a success indicator)
+ if (client && address) {
+ return (
+
+
+ ✅ Wallet Ready ({walletType})
+
+
+ You can now sign transactions
+
+
+ );
+ }
+
+ // If there's an error, show it
+ if (error) {
+ return (
+
+
+ ❌ Wallet Error
+
+
+ {error.message}
+
+
+ {walletType === 'walletconnect' && (
+
+ )}
+
+
+ );
+ }
+
+ // If not ready yet, show loading
+ if (!isReady) {
+ console.log('WalletManager: Not ready yet, showing loading...');
+ return (
+
+
⏳ Loading wallet...
+
+ Check debug panel for details
+
+
+ );
+ }
+
+ console.log('WalletManager: Ready! Checking wallet type...');
+
+ // If wallet type is known but no client, show appropriate initialization
+ console.log('Checking wallet type:', walletType, 'has mnemonic:', hasMnemonic(), 'has client:', !!client);
+
+ if (walletType === 'local' && !hasMnemonic() && !client) {
+ return (
+
+
+ 🔐 Enter Mnemonic to Sign
+
+
+ Your mnemonic is not saved. Enter it to enable signing.
+
+
+ );
+ }
+
+ if (walletType === 'keplr' && !client) {
+ return (
+
+
+ 🦊 Connect Keplr
+
+
+ Connect your Keplr wallet to sign transactions.
+
+
+
+ );
+ }
+
+ if (walletType === 'walletconnect' && !client) {
+ return (
+
+
+ 🔗 Reconnect WalletConnect
+
+
+ Reconnect to your WalletConnect session to sign transactions.
+
+
+
+ );
+ }
+
+ // No UI needed if everything is working
+ console.log('WalletManager: All good, returning null');
+ return null;
+}
diff --git a/frontend/src/config/contracts.ts b/frontend/src/config/contracts.ts
new file mode 100644
index 0000000..519d6e6
--- /dev/null
+++ b/frontend/src/config/contracts.ts
@@ -0,0 +1,136 @@
+/**
+ * Contract Configuration
+ * Update these values with your deployed contract addresses
+ */
+
+export interface ContractConfig {
+ address: string;
+ codeId?: number;
+}
+
+export interface NetworkConfig {
+ chainId: string;
+ chainName: string;
+ rpcEndpoint: string;
+ restEndpoint: string;
+ prefix: string;
+ denom: string;
+ decimals: number;
+}
+
+// Network configurations
+export const NETWORKS: Record = {
+ "neutron-1": {
+ chainId: "neutron-1",
+ chainName: "Neutron",
+ rpcEndpoint: "https://neutron-rpc.publicnode.com:443",
+ restEndpoint: "https://neutron-rest.publicnode.com:443",
+ prefix: "neutron",
+ denom: "untrn",
+ decimals: 6,
+ },
+ "pion-1": {
+ chainId: "pion-1",
+ chainName: "Neutron Testnet",
+ rpcEndpoint: "https://rpc-palvus.pion-1.ntrn.tech",
+ restEndpoint: "https://rest-palvus.pion-1.ntrn.tech",
+ prefix: "neutron",
+ denom: "untrn",
+ decimals: 6,
+ },
+};
+
+// Contract addresses by network
+export const CONTRACTS: Record> = {
+ "neutron-1": {
+ payments: {
+ // TODO: Adjust to deployed contract address
+ address: "neutron13nj4jrt88cs594fcga4q60qfzk4akwm7k3wph4",
+ codeId: undefined,
+ },
+ },
+};
+
+// Get current network (can be overridden via env var or local storage)
+export function getCurrentNetwork(): string {
+ // Check localStorage first
+ const stored = localStorage.getItem("selected_network");
+ if (stored && NETWORKS[stored]) {
+ return stored;
+ }
+
+ // Default to mainnet
+ return "neutron-1";
+}
+
+// Get network config
+export function getNetworkConfig(networkId?: string): NetworkConfig {
+ const network = networkId || getCurrentNetwork();
+ const config = NETWORKS[network];
+
+ if (!config) {
+ throw new Error(`Network ${network} not found in configuration`);
+ }
+
+ return config;
+}
+
+// Get contract address
+export function getContractAddress(
+ contractName: string,
+ networkId?: string
+): string {
+ const network = networkId || getCurrentNetwork();
+ const contract = CONTRACTS[network]?.[contractName];
+
+ if (!contract) {
+ throw new Error(
+ `Contract ${contractName} not found for network ${network}`
+ );
+ }
+
+ if (contract.address === "neutron1...") {
+ throw new Error(
+ `Contract ${contractName} address not configured. Please update src/config/contracts.ts with the deployed contract address.`
+ );
+ }
+
+ return contract.address;
+}
+
+// Set contract address (useful for development)
+export function setContractAddress(
+ contractName: string,
+ address: string,
+ networkId?: string
+): void {
+ const network = networkId || getCurrentNetwork();
+
+ if (!CONTRACTS[network]) {
+ CONTRACTS[network] = {};
+ }
+
+ CONTRACTS[network][contractName] = {
+ address,
+ };
+
+ // Also save to localStorage for persistence
+ localStorage.setItem(`contract_${network}_${contractName}`, address);
+}
+
+// Load contract addresses from localStorage (for development)
+export function loadContractAddressesFromStorage(): void {
+ Object.keys(NETWORKS).forEach((network) => {
+ Object.keys(CONTRACTS[network] || {}).forEach((contractName) => {
+ const stored = localStorage.getItem(
+ `contract_${network}_${contractName}`
+ );
+ if (stored) {
+ CONTRACTS[network][contractName].address = stored;
+ }
+ });
+ });
+}
+
+// Initialize on import
+loadContractAddressesFromStorage();
diff --git a/frontend/src/contexts/WalletContext.tsx b/frontend/src/contexts/WalletContext.tsx
new file mode 100644
index 0000000..0339a6a
--- /dev/null
+++ b/frontend/src/contexts/WalletContext.tsx
@@ -0,0 +1,167 @@
+import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
+import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing';
+import { getStoredAccount, getStoredMnemonic, type StoredAccount } from '../services/storage';
+import { getSigningCosmWasmClient, type ISigningCosmWasmClient } from '../contracts/baseClient';
+
+export type WalletType = 'local' | 'keplr' | 'walletconnect';
+
+interface WalletContextValue {
+ account: StoredAccount | null;
+ walletType: WalletType | null;
+ signingClient: ISigningCosmWasmClient | null;
+ isReady: boolean;
+
+ // Initialize wallet with mnemonic (for local accounts)
+ initializeWithMnemonic: (mnemonic: string) => Promise;
+
+ // Initialize wallet with Keplr
+ initializeWithKeplr: (chainId?: string) => Promise;
+
+ // Initialize wallet with WalletConnect
+ initializeWithWalletConnect: (chainId?: string) => Promise;
+
+ // Disconnect wallet
+ disconnect: () => void;
+}
+
+const WalletContext = createContext(undefined);
+
+interface WalletProviderProps {
+ children: ReactNode;
+ rpcEndpoint?: string;
+ chainId?: string;
+}
+
+export function WalletProvider({
+ children,
+ rpcEndpoint = 'https://neutron-rpc.publicnode.com:443',
+ chainId = 'neutron-1'
+}: WalletProviderProps) {
+ const [account, setAccount] = useState(null);
+ const [walletType, setWalletType] = useState(null);
+ const [signingClient, setSigningClient] = useState(null);
+ const [isReady, setIsReady] = useState(false);
+
+ // Try to auto-initialize on mount
+ useEffect(() => {
+ const storedAccount = getStoredAccount();
+ if (storedAccount) {
+ setAccount(storedAccount);
+
+ // Try to auto-initialize if we have a stored mnemonic
+ const storedMnemonic = getStoredMnemonic();
+ if (storedMnemonic) {
+ initializeWithMnemonic(storedMnemonic).catch(console.error);
+ } else {
+ // Account exists but no mnemonic - user needs to manually initialize
+ setIsReady(true);
+ }
+ } else {
+ setIsReady(true);
+ }
+ }, []);
+
+ const initializeWithMnemonic = async (mnemonic: string) => {
+ try {
+ // Create wallet from mnemonic
+ const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, {
+ prefix: 'neutron'
+ });
+
+ // Get accounts to verify
+ const accounts = await wallet.getAccounts();
+ const walletAccount = accounts[0];
+
+ // Create signing client using InterchainJS
+ const client = getSigningCosmWasmClient(wallet as any, rpcEndpoint);
+
+ setAccount(getStoredAccount());
+ setWalletType('local');
+ setSigningClient(client);
+ setIsReady(true);
+
+ console.log('Wallet initialized with mnemonic:', walletAccount.address);
+ } catch (error) {
+ console.error('Failed to initialize wallet with mnemonic:', error);
+ throw error;
+ }
+ };
+
+ const initializeWithKeplr = async (keplrChainId: string = chainId) => {
+ try {
+ if (!window.keplr) {
+ throw new Error('Keplr extension not found');
+ }
+
+ // Enable Keplr for the chain
+ await window.keplr.enable(keplrChainId);
+
+ // Get the offline signer
+ const offlineSigner = window.keplr.getOfflineSigner(keplrChainId);
+
+ // Create signing client
+ const client = getSigningCosmWasmClient(offlineSigner as any, rpcEndpoint);
+
+ setAccount(getStoredAccount());
+ setWalletType('keplr');
+ setSigningClient(client);
+ setIsReady(true);
+
+ console.log('Wallet initialized with Keplr');
+ } catch (error) {
+ console.error('Failed to initialize wallet with Keplr:', error);
+ throw error;
+ }
+ };
+
+ const initializeWithWalletConnect = async (_wcChainId: string = chainId) => {
+ try {
+ // For WalletConnect, we'll need to implement a custom signer
+ // This is more complex and would require maintaining the WalletConnect session
+ // For now, throw an error indicating it's not yet implemented
+ throw new Error('WalletConnect signing is not yet implemented. Please use the import mnemonic method or Keplr wallet.');
+ } catch (error) {
+ console.error('Failed to initialize wallet with WalletConnect:', error);
+ throw error;
+ }
+ };
+
+ const disconnect = () => {
+ setAccount(null);
+ setWalletType(null);
+ setSigningClient(null);
+ setIsReady(true);
+ };
+
+ const value: WalletContextValue = {
+ account,
+ walletType,
+ signingClient,
+ isReady,
+ initializeWithMnemonic,
+ initializeWithKeplr,
+ initializeWithWalletConnect,
+ disconnect,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useWallet() {
+ const context = useContext(WalletContext);
+ if (context === undefined) {
+ throw new Error('useWallet must be used within a WalletProvider');
+ }
+ return context;
+}
+
+// Extend window type for Keplr
+declare global {
+ interface Window {
+ keplr?: any;
+ }
+}
diff --git a/frontend/src/contracts/TgContractPayments.client.ts b/frontend/src/contracts/TgContractPayments.client.ts
index 7c82829..e3bee15 100644
--- a/frontend/src/contracts/TgContractPayments.client.ts
+++ b/frontend/src/contracts/TgContractPayments.client.ts
@@ -1,35 +1,43 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
-* This file was automatically generated by @cosmwasm/ts-codegen@1.13.3.
-* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
-* and run the @cosmwasm/ts-codegen generate command to regenerate this file.
-*/
+ * This file was automatically generated by @cosmwasm/ts-codegen@1.13.3.
+ * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
+ * and run the @cosmwasm/ts-codegen generate command to regenerate this file.
+ */
// @ts-nocheck
-import { ICosmWasmClient, ISigningCosmWasmClient } from "./baseClient";
-import { StdFee } from "@interchainjs/types";
-import { Auth, InstantiateMsg, ExecuteMsg, Uint256, ServiceHandlerExecuteMessages, WavsEnvelope, Binary, HexBinary, EvmAddr, RegisterReceiveMsg, SendPaymentMsg, WavsSignatureData, QueryMsg, ServiceHandlerQueryMessages, ChainAddrResponse, AdminResponse, ArrayOfString, ArrayOfCoin, Coin, TgHandleResponse, Null } from "./TgContractPayments.types";
+import type {
+ ICosmWasmClient,
+ ISigningCosmWasmClient,
+ StdFee,
+ Coin as BaseCoin,
+} from "./baseClient";
+import type {
+ Uint256,
+ ServiceHandlerExecuteMessages,
+ ServiceHandlerQueryMessages,
+ ChainAddrResponse,
+ ArrayOfString,
+ ArrayOfCoin,
+ Coin,
+ TgHandleResponse,
+ Null,
+} from "./TgContractPayments.types";
export interface TgContractPaymentsReadOnlyInterface {
contractAddress: string;
- addrByTg: ({
- handle
- }: {
- handle: string;
- }) => Promise;
- tgByAddr: ({
- account
- }: {
- account: string;
- }) => Promise;
- admin: () => Promise;
- pendingPayments: ({
- handle
- }: {
- handle: string;
- }) => Promise;
+ addrByTg: ({ handle }: { handle: string }) => Promise;
+ tgByAddr: ({ account }: { account: string }) => Promise;
+ admin: () => Promise;
+ pendingPayments: ({ handle }: { handle: string }) => Promise;
allowedDenoms: () => Promise;
- wavs: (serviceHandlerQueryMessages: ServiceHandlerQueryMessages) => Promise;
+ wavs: (
+ serviceHandlerQueryMessages: ServiceHandlerQueryMessages
+ ) => Promise;
}
-export class TgContractPaymentsQueryClient implements TgContractPaymentsReadOnlyInterface {
+export class TgContractPaymentsQueryClient
+ implements TgContractPaymentsReadOnlyInterface
+{
client: ICosmWasmClient;
contractAddress: string;
constructor(client: ICosmWasmClient, contractAddress: string) {
@@ -43,87 +51,117 @@ export class TgContractPaymentsQueryClient implements TgContractPaymentsReadOnly
this.wavs = this.wavs.bind(this);
}
addrByTg = async ({
- handle
+ handle,
}: {
handle: string;
}): Promise => {
return this.client.queryContractSmart(this.contractAddress, {
addr_by_tg: {
- handle
- }
+ handle,
+ },
});
};
tgByAddr = async ({
- account
+ account,
}: {
account: string;
}): Promise => {
return this.client.queryContractSmart(this.contractAddress, {
tg_by_addr: {
- account
- }
+ account,
+ },
});
};
admin = async (): Promise => {
return this.client.queryContractSmart(this.contractAddress, {
- admin: {}
+ admin: {},
});
};
pendingPayments = async ({
- handle
+ handle,
}: {
handle: string;
}): Promise => {
return this.client.queryContractSmart(this.contractAddress, {
pending_payments: {
- handle
- }
+ handle,
+ },
});
};
allowedDenoms = async (): Promise => {
return this.client.queryContractSmart(this.contractAddress, {
- allowed_denoms: {}
+ allowed_denoms: {},
});
};
- wavs = async (serviceHandlerQueryMessages: ServiceHandlerQueryMessages): Promise => {
+ wavs = async (
+ serviceHandlerQueryMessages: ServiceHandlerQueryMessages
+ ): Promise => {
return this.client.queryContractSmart(this.contractAddress, {
- wavs: serviceHandlerQueryMessages
+ wavs: serviceHandlerQueryMessages,
});
};
}
-export interface TgContractPaymentsInterface extends TgContractPaymentsReadOnlyInterface {
+export interface TgContractPaymentsInterface
+ extends TgContractPaymentsReadOnlyInterface {
contractAddress: string;
sender: string;
- registerReceive: ({
- chainAddr,
- tgHandle
- }: {
- chainAddr: string;
- tgHandle: string;
- }, fee_?: number | StdFee | "auto", memo_?: string, funds_?: Coin[]) => Promise;
- sendPayment: ({
- amount,
- denom,
- fromTg,
- toTg
- }: {
- amount: Uint256;
- denom: string;
- fromTg: string;
- toTg: string;
- }, fee_?: number | StdFee | "auto", memo_?: string, funds_?: Coin[]) => Promise;
- registerSend: ({
- tgHandle
- }: {
- tgHandle: string;
- }, fee_?: number | StdFee | "auto", memo_?: string, funds_?: Coin[]) => Promise;
- wavs: (serviceHandlerExecuteMessages: ServiceHandlerExecuteMessages, fee_?: number | StdFee | "auto", memo_?: string, funds_?: Coin[]) => Promise;
+ registerReceive: (
+ {
+ chainAddr,
+ tgHandle,
+ }: {
+ chainAddr: string;
+ tgHandle: string;
+ },
+ fee_?: number | StdFee | "auto",
+ memo_?: string,
+ funds_?: BaseCoin[]
+ ) => Promise;
+ sendPayment: (
+ {
+ amount,
+ denom,
+ fromTg,
+ toTg,
+ }: {
+ amount: Uint256;
+ denom: string;
+ fromTg: string;
+ toTg: string;
+ },
+ fee_?: number | StdFee | "auto",
+ memo_?: string,
+ funds_?: BaseCoin[]
+ ) => Promise;
+ registerSend: (
+ {
+ tgHandle,
+ }: {
+ tgHandle: string;
+ },
+ fee_?: number | StdFee | "auto",
+ memo_?: string,
+ funds_?: BaseCoin[]
+ ) => Promise;
+ wavs: (
+ serviceHandlerExecuteMessages: ServiceHandlerExecuteMessages,
+ fee_?: number | StdFee | "auto",
+ memo_?: string,
+ funds_?: BaseCoin[]
+ ) => Promise;
}
-export class TgContractPaymentsClient extends TgContractPaymentsQueryClient implements TgContractPaymentsInterface {
+export class TgContractPaymentsClient
+ extends TgContractPaymentsQueryClient
+ implements TgContractPaymentsInterface
+{
client: ISigningCosmWasmClient;
sender: string;
contractAddress: string;
- constructor(client: ISigningCosmWasmClient, sender: string, contractAddress: string) {
+ constructor(
+ client: ISigningCosmWasmClient,
+ sender: string,
+ contractAddress: string
+ ) {
super(client, contractAddress);
this.client = client;
this.sender = sender;
@@ -133,54 +171,102 @@ export class TgContractPaymentsClient extends TgContractPaymentsQueryClient impl
this.registerSend = this.registerSend.bind(this);
this.wavs = this.wavs.bind(this);
}
- registerReceive = async ({
- chainAddr,
- tgHandle
- }: {
- chainAddr: string;
- tgHandle: string;
- }, fee_: number | StdFee | "auto" = "auto", memo_?: string, funds_?: Coin[]): Promise => {
- return await this.client.execute(this.sender, this.contractAddress, {
- register_receive: {
- chain_addr: chainAddr,
- tg_handle: tgHandle
- }
- }, fee_, memo_, funds_);
+ registerReceive = async (
+ {
+ chainAddr,
+ tgHandle,
+ }: {
+ chainAddr: string;
+ tgHandle: string;
+ },
+ fee_: number | StdFee | "auto" = "auto",
+ memo_?: string,
+ funds_?: Coin[]
+ ): Promise => {
+ return await this.client.execute(
+ this.sender,
+ this.contractAddress,
+ {
+ register_receive: {
+ chain_addr: chainAddr,
+ tg_handle: tgHandle,
+ },
+ },
+ fee_,
+ memo_,
+ funds_
+ );
};
- sendPayment = async ({
- amount,
- denom,
- fromTg,
- toTg
- }: {
- amount: Uint256;
- denom: string;
- fromTg: string;
- toTg: string;
- }, fee_: number | StdFee | "auto" = "auto", memo_?: string, funds_?: Coin[]): Promise => {
- return await this.client.execute(this.sender, this.contractAddress, {
- send_payment: {
- amount,
- denom,
- from_tg: fromTg,
- to_tg: toTg
- }
- }, fee_, memo_, funds_);
+ sendPayment = async (
+ {
+ amount,
+ denom,
+ fromTg,
+ toTg,
+ }: {
+ amount: Uint256;
+ denom: string;
+ fromTg: string;
+ toTg: string;
+ },
+ fee_: number | StdFee | "auto" = "auto",
+ memo_?: string,
+ funds_?: Coin[]
+ ): Promise => {
+ return await this.client.execute(
+ this.sender,
+ this.contractAddress,
+ {
+ send_payment: {
+ amount,
+ denom,
+ from_tg: fromTg,
+ to_tg: toTg,
+ },
+ },
+ fee_,
+ memo_,
+ funds_
+ );
};
- registerSend = async ({
- tgHandle
- }: {
- tgHandle: string;
- }, fee_: number | StdFee | "auto" = "auto", memo_?: string, funds_?: Coin[]): Promise => {
- return await this.client.execute(this.sender, this.contractAddress, {
- register_send: {
- tg_handle: tgHandle
- }
- }, fee_, memo_, funds_);
+ registerSend = async (
+ {
+ tgHandle,
+ }: {
+ tgHandle: string;
+ },
+ fee_: number | StdFee | "auto" = "auto",
+ memo_?: string,
+ funds_?: Coin[]
+ ): Promise => {
+ return await this.client.execute(
+ this.sender,
+ this.contractAddress,
+ {
+ register_send: {
+ tg_handle: tgHandle,
+ },
+ },
+ fee_,
+ memo_,
+ funds_
+ );
};
- wavs = async (serviceHandlerExecuteMessages: ServiceHandlerExecuteMessages, fee_: number | StdFee | "auto" = "auto", memo_?: string, funds_?: Coin[]): Promise => {
- return await this.client.execute(this.sender, this.contractAddress, {
- wavs: serviceHandlerExecuteMessages
- }, fee_, memo_, funds_);
+ wavs = async (
+ serviceHandlerExecuteMessages: ServiceHandlerExecuteMessages,
+ fee_: number | StdFee | "auto" = "auto",
+ memo_?: string,
+ funds_?: Coin[]
+ ): Promise => {
+ return await this.client.execute(
+ this.sender,
+ this.contractAddress,
+ {
+ wavs: serviceHandlerExecuteMessages,
+ },
+ fee_,
+ memo_,
+ funds_
+ );
};
-}
\ No newline at end of file
+}
diff --git a/frontend/src/contracts/TgContractPayments.message-composer.ts b/frontend/src/contracts/TgContractPayments.message-composer.ts
deleted file mode 100644
index f6e59bc..0000000
--- a/frontend/src/contracts/TgContractPayments.message-composer.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
-* This file was automatically generated by @cosmwasm/ts-codegen@1.13.3.
-* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
-* and run the @cosmwasm/ts-codegen generate command to regenerate this file.
-*/
-// @ts-nocheck
-
-import { EncodeObject } from "@interchainjs/cosmos-types";
-import { MsgExecuteContract } from "interchainjs/cosmwasm/wasm/v1/tx";
-import { toUtf8 } from "@interchainjs/encoding";
-import { Auth, InstantiateMsg, ExecuteMsg, Uint256, ServiceHandlerExecuteMessages, WavsEnvelope, Binary, HexBinary, EvmAddr, RegisterReceiveMsg, SendPaymentMsg, WavsSignatureData, QueryMsg, ServiceHandlerQueryMessages, ChainAddrResponse, AdminResponse, ArrayOfString, ArrayOfCoin, Coin, TgHandleResponse, Null } from "./TgContractPayments.types";
-export interface TgContractPaymentsMsg {
- contractAddress: string;
- sender: string;
- registerReceive: ({
- chainAddr,
- tgHandle
- }: {
- chainAddr: string;
- tgHandle: string;
- }, funds_?: Coin[]) => EncodeObject;
- sendPayment: ({
- amount,
- denom,
- fromTg,
- toTg
- }: {
- amount: Uint256;
- denom: string;
- fromTg: string;
- toTg: string;
- }, funds_?: Coin[]) => EncodeObject;
- registerSend: ({
- tgHandle
- }: {
- tgHandle: string;
- }, funds_?: Coin[]) => EncodeObject;
- wavs: (serviceHandlerExecuteMessages: ServiceHandlerExecuteMessages, funds_?: Coin[]) => EncodeObject;
-}
-export class TgContractPaymentsMsgComposer implements TgContractPaymentsMsg {
- sender: string;
- contractAddress: string;
- constructor(sender: string, contractAddress: string) {
- this.sender = sender;
- this.contractAddress = contractAddress;
- this.registerReceive = this.registerReceive.bind(this);
- this.sendPayment = this.sendPayment.bind(this);
- this.registerSend = this.registerSend.bind(this);
- this.wavs = this.wavs.bind(this);
- }
- registerReceive = ({
- chainAddr,
- tgHandle
- }: {
- chainAddr: string;
- tgHandle: string;
- }, funds_?: Coin[]): EncodeObject => {
- return {
- typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
- value: MsgExecuteContract.fromPartial({
- sender: this.sender,
- contract: this.contractAddress,
- msg: toUtf8(JSON.stringify({
- register_receive: {
- chain_addr: chainAddr,
- tg_handle: tgHandle
- }
- })),
- funds: funds_
- })
- };
- };
- sendPayment = ({
- amount,
- denom,
- fromTg,
- toTg
- }: {
- amount: Uint256;
- denom: string;
- fromTg: string;
- toTg: string;
- }, funds_?: Coin[]): EncodeObject => {
- return {
- typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
- value: MsgExecuteContract.fromPartial({
- sender: this.sender,
- contract: this.contractAddress,
- msg: toUtf8(JSON.stringify({
- send_payment: {
- amount,
- denom,
- from_tg: fromTg,
- to_tg: toTg
- }
- })),
- funds: funds_
- })
- };
- };
- registerSend = ({
- tgHandle
- }: {
- tgHandle: string;
- }, funds_?: Coin[]): EncodeObject => {
- return {
- typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
- value: MsgExecuteContract.fromPartial({
- sender: this.sender,
- contract: this.contractAddress,
- msg: toUtf8(JSON.stringify({
- register_send: {
- tg_handle: tgHandle
- }
- })),
- funds: funds_
- })
- };
- };
- wavs = (serviceHandlerExecuteMessages: ServiceHandlerExecuteMessages, funds_?: Coin[]): EncodeObject => {
- return {
- typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
- value: MsgExecuteContract.fromPartial({
- sender: this.sender,
- contract: this.contractAddress,
- msg: toUtf8(JSON.stringify({
- wavs: serviceHandlerExecuteMessages
- })),
- funds: funds_
- })
- };
- };
-}
\ No newline at end of file
diff --git a/frontend/src/contracts/TgContractPayments.types.ts b/frontend/src/contracts/TgContractPayments.types.ts
index 26840c2..bffda2a 100644
--- a/frontend/src/contracts/TgContractPayments.types.ts
+++ b/frontend/src/contracts/TgContractPayments.types.ts
@@ -1,30 +1,36 @@
+/* eslint-disable @typescript-eslint/no-empty-object-type */
/**
-* This file was automatically generated by @cosmwasm/ts-codegen@1.13.3.
-* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
-* and run the @cosmwasm/ts-codegen generate command to regenerate this file.
-*/
-// @ts-nocheck
+ * This file was automatically generated by @cosmwasm/ts-codegen@1.13.3.
+ * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
+ * and run the @cosmwasm/ts-codegen generate command to regenerate this file.
+ */
-export type Auth = {
- service_manager: string;
-} | {
- admin: string;
-};
+export type Auth =
+ | {
+ service_manager: string;
+ }
+ | {
+ admin: string;
+ };
export interface InstantiateMsg {
allowed_denoms: string[];
auth: Auth;
}
-export type ExecuteMsg = {
- register_receive: RegisterReceiveMsg;
-} | {
- send_payment: SendPaymentMsg;
-} | {
- register_send: {
- tg_handle: string;
- };
-} | {
- wavs: ServiceHandlerExecuteMessages;
-};
+export type ExecuteMsg =
+ | {
+ register_receive: RegisterReceiveMsg;
+ }
+ | {
+ send_payment: SendPaymentMsg;
+ }
+ | {
+ register_send: {
+ tg_handle: string;
+ };
+ }
+ | {
+ wavs: ServiceHandlerExecuteMessages;
+ };
export type Uint256 = string;
export type ServiceHandlerExecuteMessages = {
wavs_handle_signed_envelope: {
@@ -51,25 +57,31 @@ export interface WavsSignatureData {
signatures: HexBinary[];
signers: EvmAddr[];
}
-export type QueryMsg = {
- addr_by_tg: {
- handle: string;
- };
-} | {
- tg_by_addr: {
- account: string;
- };
-} | {
- admin: {};
-} | {
- pending_payments: {
- handle: string;
- };
-} | {
- allowed_denoms: {};
-} | {
- wavs: ServiceHandlerQueryMessages;
-};
+export type QueryMsg =
+ | {
+ addr_by_tg: {
+ handle: string;
+ };
+ }
+ | {
+ tg_by_addr: {
+ account: string;
+ };
+ }
+ | {
+ admin: {};
+ }
+ | {
+ pending_payments: {
+ handle: string;
+ };
+ }
+ | {
+ allowed_denoms: {};
+ }
+ | {
+ wavs: ServiceHandlerQueryMessages;
+ };
export type ServiceHandlerQueryMessages = {
wavs_service_manager: {};
};
@@ -88,4 +100,4 @@ export interface Coin {
export interface TgHandleResponse {
handle?: string | null;
}
-export type Null = null;
\ No newline at end of file
+export type Null = null;
diff --git a/frontend/src/contracts/baseClient.ts b/frontend/src/contracts/baseClient.ts
index 89e6152..b84e6e0 100644
--- a/frontend/src/contracts/baseClient.ts
+++ b/frontend/src/contracts/baseClient.ts
@@ -6,13 +6,28 @@
// @ts-nocheck
-import { StdFee, Coin } from '@interchainjs/types';
-import { DirectSigner } from '@interchainjs/cosmos';
+// @ts-nocheck - Generated file with type issues
+import type { DirectSigner } from '@interchainjs/cosmos';
import { getSmartContractState } from 'interchainjs/cosmwasm/wasm/v1/query.rpc.func';
import { executeContract } from 'interchainjs/cosmwasm/wasm/v1/tx.rpc.func';
-import { QuerySmartContractStateRequest, QuerySmartContractStateResponse } from 'interchainjs/cosmwasm/wasm/v1/query';
-import { MsgExecuteContract } from 'interchainjs/cosmwasm/wasm/v1/tx';
-import { Chain } from '@chain-registry/v2-types';
+import type { QuerySmartContractStateRequest, QuerySmartContractStateResponse } from 'interchainjs/cosmwasm/wasm/v1/query';
+import type { MsgExecuteContract } from 'interchainjs/cosmwasm/wasm/v1/tx';
+import { GasPrice } from '@cosmjs/stargate';
+import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
+import type { OfflineDirectSigner } from '@cosmjs/proto-signing';
+
+// Define types that might not be exported properly
+export type StdFee = {
+ amount: Array<{ denom: string; amount: string }>;
+ gas: string;
+};
+
+export type Coin = {
+ denom: string;
+ amount: string;
+};
+
+export type Chain = any; // Use any for now since the import is broken
// Encoding utility functions
const fromUint8Array = (uint8Array: Uint8Array): T => {
@@ -28,7 +43,7 @@ const toUint8Array = (obj: any): Uint8Array => {
// Chain registry configuration
// The amount under gasPrice represents gas price per unit
export interface ChainConfig {
- chain?: Chain;
+ chain?: any; // Chain type from registry
gasPrice?: {
denom: string;
amount: string;
@@ -36,7 +51,7 @@ export interface ChainConfig {
}
// Gas fee calculation utilities
-export const calculateGasFromChain = (chain: Chain, gasAmount: string): StdFee => {
+export const calculateGasFromChain = (chain: any, gasAmount: string): StdFee => {
try {
const feeTokens = chain.fees?.feeTokens;
@@ -106,14 +121,21 @@ export interface ICosmWasmClient {
export interface ISigningCosmWasmClient extends ICosmWasmClient {
execute(
- sender: string,
- contractAddress: string,
- msg: any,
- fee?: number | StdFee | "auto",
- memo?: string,
- funds?: Coin[],
+ sender: string,
+ contractAddress: string,
+ msg: any,
+ fee?: number | StdFee | "auto",
+ memo?: string,
+ funds?: Coin[],
chainConfig?: ChainConfig
): Promise;
+
+ signAndBroadcast(
+ signerAddress: string,
+ messages: any[],
+ fee: number | StdFee | "auto",
+ memo?: string
+ ): Promise;
}
export interface ISigningClient {
@@ -145,6 +167,25 @@ export function getCosmWasmClient(rpcEndpoint: string): ICosmWasmClient {
}
export function getSigningCosmWasmClient(signingClient: DirectSigner, rpcEndpoint?: string): ISigningCosmWasmClient {
+ // Create a SigningCosmWasmClient wrapper that has signAndBroadcast
+ let cosmwasmClient: SigningCosmWasmClient | null = null;
+
+ const getCosmWasmClient = async (): Promise => {
+ if (!cosmwasmClient) {
+ if (!rpcEndpoint) {
+ throw new Error('rpcEndpoint is required for signing operations');
+ }
+ // Set gas price for Neutron (0.025untrn is a reasonable default)
+ const gasPrice = GasPrice.fromString('0.025untrn');
+ cosmwasmClient = await SigningCosmWasmClient.connectWithSigner(
+ rpcEndpoint,
+ signingClient as unknown as OfflineDirectSigner,
+ { gasPrice }
+ );
+ }
+ return cosmwasmClient;
+ };
+
return {
queryContractSmart: async (contractAddr: string, query: any) => {
if (!rpcEndpoint) {
@@ -157,6 +198,12 @@ export function getSigningCosmWasmClient(signingClient: DirectSigner, rpcEndpoin
const response: QuerySmartContractStateResponse = await getSmartContractState(rpcEndpoint, request);
return fromUint8Array(response.data);
},
+
+ signAndBroadcast: async (signerAddress: string, messages: any[], fee: number | StdFee | "auto", memo?: string) => {
+ // Get the SigningCosmWasmClient which has signAndBroadcast
+ const client = await getCosmWasmClient();
+ return await client.signAndBroadcast(signerAddress, messages, fee as any, memo);
+ },
execute: async (
sender: string,
contractAddress: string,
diff --git a/frontend/src/debug.ts b/frontend/src/debug.ts
new file mode 100644
index 0000000..7ef2113
--- /dev/null
+++ b/frontend/src/debug.ts
@@ -0,0 +1,59 @@
+/**
+ * Debug utilities for displaying errors on screen
+ * (since Telegram browser doesn't have console)
+ */
+
+export function createDebugPanel() {
+ const panel = document.createElement("div");
+ panel.id = "debug-panel";
+ panel.style.cssText = `
+ position: fixed;
+ top: 10px;
+ left: 10px;
+ right: 10px;
+ max-height: 300px;
+ overflow-y: auto;
+ background: rgba(0, 0, 0, 0.9);
+ color: #0f0;
+ padding: 10px;
+ font-family: monospace;
+ font-size: 11px;
+ z-index: 99999;
+ border: 2px solid #0f0;
+ border-radius: 4px;
+ `;
+ document.body.appendChild(panel);
+ return panel;
+}
+
+export function log(message: string, type: "info" | "error" | "warn" = "info") {
+ console.log(`[${type}]`, message);
+
+ // Comment back in if you want to see debug panel
+ // let panel = document.getElementById('debug-panel');
+ // if (!panel) {
+ // panel = createDebugPanel();
+ // }
+
+ const color = type === "error" ? "#f00" : type === "warn" ? "#ff0" : "#0f0";
+ const timestamp = new Date().toLocaleTimeString();
+
+ const line = document.createElement("div");
+ line.style.cssText = `color: ${color}; margin-bottom: 4px;`;
+ line.textContent = `[${timestamp}] ${message}`;
+ // panel.appendChild(line);
+
+ // Auto scroll to bottom
+ // panel.scrollTop = panel.scrollHeight;
+}
+
+export function clearDebug() {
+ const panel = document.getElementById("debug-panel");
+ if (panel) {
+ panel.remove();
+ }
+}
+
+// Make it available globally
+(window as any).debugLog = log;
+(window as any).clearDebug = clearDebug;
diff --git a/frontend/src/hooks/useSigningClient.ts b/frontend/src/hooks/useSigningClient.ts
new file mode 100644
index 0000000..45feec8
--- /dev/null
+++ b/frontend/src/hooks/useSigningClient.ts
@@ -0,0 +1,171 @@
+/**
+ * Hook for managing CosmWasm signing client
+ */
+
+import { useState, useEffect } from 'react';
+import { type ISigningCosmWasmClient } from '../contracts/baseClient';
+import { getStoredAccount, getStoredMnemonic, getStoredWalletType } from '../services/storage';
+import {
+ createSignerFromMnemonic,
+ createSignerFromKeplr,
+ createSignerFromWalletConnect,
+ DEFAULT_RPC_ENDPOINT,
+ DEFAULT_CHAIN_ID,
+} from '../services/signer';
+import { log } from '../debug';
+
+export interface UseSigningClientResult {
+ client: ISigningCosmWasmClient | null;
+ address: string | null;
+ walletType: 'local' | 'keplr' | 'walletconnect' | null;
+ isReady: boolean;
+ error: Error | null;
+
+ // Initialize with mnemonic
+ initializeWithMnemonic: (mnemonic: string) => Promise;
+
+ // Initialize with Keplr
+ initializeWithKeplr: () => Promise;
+
+ // Initialize with WalletConnect
+ initializeWithWalletConnect: () => Promise;
+
+ // Reinitialize (useful after page reload)
+ reinitialize: () => Promise;
+}
+
+export function useSigningClient(
+ rpcEndpoint: string = DEFAULT_RPC_ENDPOINT,
+ chainId: string = DEFAULT_CHAIN_ID
+): UseSigningClientResult {
+ const [client, setClient] = useState(null);
+ const [address, setAddress] = useState(null);
+ const [walletType, setWalletType] = useState<'local' | 'keplr' | 'walletconnect' | null>(null);
+ const [isReady, setIsReady] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Auto-initialize on mount
+ useEffect(() => {
+ reinitialize();
+ }, []);
+
+ const reinitialize = async () => {
+ try {
+ log('🔄 Reinitializing wallet...');
+ const storedAccount = getStoredAccount();
+ if (!storedAccount) {
+ log('No stored account, setting ready');
+ setIsReady(true);
+ return;
+ }
+
+ const storedType = getStoredWalletType();
+ const storedMnemonic = getStoredMnemonic();
+ log(`Stored wallet type: ${storedType}`);
+
+ // If we have a mnemonic stored, use it
+ if (storedMnemonic) {
+ log('Reinitializing with stored mnemonic...');
+ await initializeWithMnemonic(storedMnemonic);
+ }
+ // If wallet type is Keplr, try to connect to Keplr
+ else if (storedType === 'keplr') {
+ log('Reinitializing with Keplr...');
+ await initializeWithKeplr();
+ }
+ // If wallet type is WalletConnect, try to use existing session
+ else if (storedType === 'walletconnect') {
+ log('Reinitializing with WalletConnect...');
+ await initializeWithWalletConnect();
+ }
+ // Otherwise, just mark as ready (user will need to manually initialize)
+ else {
+ log('No specific wallet type, just setting address and ready');
+ setAddress(storedAccount.address);
+ setWalletType(storedType);
+ setIsReady(true);
+ }
+ } catch (err) {
+ log(`Failed to reinitialize wallet: ${(err as Error).message}`, 'error');
+ console.error('Failed to reinitialize wallet:', err);
+ setError(err as Error);
+ setIsReady(true);
+ }
+ };
+
+ const initializeWithMnemonic = async (mnemonic: string) => {
+ try {
+ setError(null);
+ const { client: signingClient, address: walletAddress } = await createSignerFromMnemonic(
+ mnemonic,
+ rpcEndpoint
+ );
+
+ setClient(signingClient);
+ setAddress(walletAddress);
+ setWalletType('local');
+ setIsReady(true);
+ } catch (err) {
+ console.error('Failed to initialize with mnemonic:', err);
+ setError(err as Error);
+ setIsReady(true); // Set ready even on error
+ throw err;
+ }
+ };
+
+ const initializeWithKeplr = async () => {
+ try {
+ setError(null);
+ const { client: signingClient, address: walletAddress } = await createSignerFromKeplr(
+ chainId,
+ rpcEndpoint
+ );
+
+ setClient(signingClient);
+ setAddress(walletAddress);
+ setWalletType('keplr');
+ setIsReady(true);
+ } catch (err) {
+ console.error('Failed to initialize with Keplr:', err);
+ setError(err as Error);
+ setIsReady(true); // Set ready even on error
+ throw err;
+ }
+ };
+
+ const initializeWithWalletConnect = async () => {
+ try {
+ setError(null);
+ log('🔗 Initializing WalletConnect signer...');
+ const { client: signingClient, address: walletAddress } = await createSignerFromWalletConnect(
+ chainId,
+ rpcEndpoint
+ );
+
+ log(`✅ WalletConnect signer created: ${walletAddress}`);
+ setClient(signingClient);
+ setAddress(walletAddress);
+ setWalletType('walletconnect');
+ setIsReady(true);
+ log('✅ WalletConnect signer ready!');
+ } catch (err) {
+ log(`❌ Failed to initialize with WalletConnect: ${(err as Error).message}`, 'error');
+ console.error('Failed to initialize with WalletConnect:', err);
+ setError(err as Error);
+ setIsReady(true); // Set ready even on error so UI can show error state
+ throw err;
+ }
+ };
+
+ return {
+ client,
+ address,
+ walletType,
+ isReady,
+ error,
+ initializeWithMnemonic,
+ initializeWithKeplr,
+ initializeWithWalletConnect,
+ reinitialize,
+ };
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index bef5202..ab99775 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,10 +1,105 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
-
-createRoot(document.getElementById('root')!).render(
-
-
- ,
-)
+import './polyfills';
+import { log } from './debug';
+
+log('🚀 Starting app...');
+
+// Add global error handler FIRST
+window.addEventListener('error', (event) => {
+ const msg = `Error: ${event.error?.message || event.message}\n${event.error?.stack || ''}`;
+ log(msg, 'error');
+ console.error('Global error:', event.error);
+
+ // Try to show in Telegram
+ try {
+ if ((window as any).Telegram?.WebApp?.showAlert) {
+ (window as any).Telegram.WebApp.showAlert(msg.substring(0, 200));
+ }
+ } catch (e) {
+ console.error('Failed to show error:', e);
+ }
+});
+
+window.addEventListener('unhandledrejection', (event) => {
+ const msg = `Promise Error: ${event.reason?.message || event.reason}\n${event.reason?.stack || ''}`;
+ log(msg, 'error');
+ console.error('Unhandled promise rejection:', event.reason);
+
+ // Try to show in Telegram
+ try {
+ if ((window as any).Telegram?.WebApp?.showAlert) {
+ (window as any).Telegram.WebApp.showAlert(msg.substring(0, 200));
+ }
+ } catch (e) {
+ console.error('Failed to show error:', e);
+ }
+});
+
+log('✅ Error handlers registered');
+
+try {
+ log('📦 Importing React...');
+ const { StrictMode } = await import('react');
+ const { createRoot } = await import('react-dom/client');
+
+ log('📦 Importing routing...');
+ const { BrowserRouter, Routes, Route } = await import('react-router-dom');
+
+ log('📦 Importing components...');
+ const { default: App } = await import('./App.tsx');
+ const { WalletConnect } = await import('./pages/WalletConnect.tsx');
+ const { ErrorBoundary } = await import('./components/ErrorBoundary');
+
+ log('📦 Importing styles...');
+ await import('./index.css');
+
+ log('🔍 Finding root element...');
+ const rootElement = document.getElementById('root');
+ if (!rootElement) {
+ throw new Error('Root element not found!');
+ }
+ log('✅ Root element found');
+
+ log('⚛️ Creating React root...');
+ const root = createRoot(rootElement);
+
+ log('⚛️ Rendering app with routing...');
+ root.render(
+
+
+
+
+ } />
+ } />
+
+
+
+
+ );
+
+ log('✅ App rendered successfully!');
+
+ // Keep debug panel visible for 10 seconds to see wallet initialization
+ setTimeout(() => {
+ const panel = document.getElementById('debug-panel');
+ if (panel && !panel.textContent?.includes('error') && !panel.textContent?.includes('❌')) {
+ panel.style.display = 'none';
+ }
+ }, 10000);
+
+} catch (error: any) {
+ log(`❌ FATAL ERROR: ${error.message}\n${error.stack}`, 'error');
+
+ // Show fallback UI
+ const root = document.getElementById('root');
+ if (root) {
+ root.innerHTML = `
+
+
Failed to start app
+
${error.message}\n\n${error.stack}
+
+
+ `;
+ }
+}
diff --git a/frontend/src/polyfills.ts b/frontend/src/polyfills.ts
new file mode 100644
index 0000000..255dca7
--- /dev/null
+++ b/frontend/src/polyfills.ts
@@ -0,0 +1,9 @@
+/**
+ * Browser polyfills for Node.js APIs
+ */
+
+import { Buffer } from 'buffer';
+
+// Make Buffer available globally
+(window as any).Buffer = Buffer;
+(window as any).global = window;
diff --git a/frontend/src/services/authz.ts b/frontend/src/services/authz.ts
new file mode 100644
index 0000000..67b4927
--- /dev/null
+++ b/frontend/src/services/authz.ts
@@ -0,0 +1,227 @@
+/**
+ * Authz Service
+ * Handles creation of Cosmos SDK authz grants
+ */
+
+import type { ISigningCosmWasmClient } from '../contracts/baseClient';
+import { log } from '../debug';
+import { SendAuthorization } from 'cosmjs-types/cosmos/bank/v1beta1/authz';
+import { Timestamp } from 'cosmjs-types/google/protobuf/timestamp';
+
+export interface AuthzGrantParams {
+ granter: string; // User's address
+ grantee: string; // Contract address
+ spendLimit: string; // Amount in untrn (e.g., "100000000" for 100 NTRN)
+ denom: string; // Usually "untrn"
+ expiration?: Date; // Optional expiration, default 1 year from now
+}
+
+/**
+ * Create an authz grant message for SendAuthorization
+ * This allows the grantee (contract) to send tokens on behalf of the granter (user)
+ */
+export function createAuthzGrantMessage(params: AuthzGrantParams) {
+ const {
+ granter,
+ grantee,
+ spendLimit,
+ denom,
+ expiration = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now
+ } = params;
+
+ log(`Creating authz grant: ${spendLimit} ${denom} for ${grantee}`);
+
+ // Create the SendAuthorization with proper protobuf encoding
+ const sendAuthz = SendAuthorization.fromPartial({
+ spendLimit: [
+ {
+ denom,
+ amount: spendLimit,
+ },
+ ],
+ });
+
+ // Encode the authorization
+ const authorizationBytes = SendAuthorization.encode(sendAuthz).finish();
+
+ // Create expiration timestamp
+ const expirationTimestamp = Timestamp.fromPartial({
+ seconds: BigInt(Math.floor(expiration.getTime() / 1000)),
+ nanos: 0,
+ });
+
+ return {
+ typeUrl: '/cosmos.authz.v1beta1.MsgGrant',
+ value: {
+ granter,
+ grantee,
+ grant: {
+ authorization: {
+ typeUrl: '/cosmos.bank.v1beta1.SendAuthorization',
+ value: authorizationBytes,
+ },
+ expiration: expirationTimestamp,
+ },
+ },
+ };
+}
+
+/**
+ * Create authz grant + registerSend in one transaction
+ */
+export async function registerWithAuthz(
+ client: ISigningCosmWasmClient,
+ params: {
+ userAddress: string;
+ contractAddress: string;
+ tgHandle: string;
+ spendLimit: string; // in NTRN (e.g., "100")
+ denom?: string;
+ }
+) {
+ const { userAddress, contractAddress, tgHandle, spendLimit, denom = 'untrn' } = params;
+
+ log(`Registering user ${tgHandle} with ${spendLimit} NTRN limit`);
+
+ // Convert NTRN to untrn (1 NTRN = 1,000,000 untrn)
+ const spendLimitMicro = (parseFloat(spendLimit) * 1_000_000).toString();
+
+ try {
+ // Create authz grant message
+ const authzMsg = createAuthzGrantMessage({
+ granter: userAddress,
+ grantee: contractAddress,
+ spendLimit: spendLimitMicro,
+ denom,
+ });
+
+ // Create registerSend message
+ const registerMsg = {
+ typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract',
+ value: {
+ sender: userAddress,
+ contract: contractAddress,
+ msg: Buffer.from(
+ JSON.stringify({
+ register_send: {
+ tg_handle: tgHandle,
+ },
+ })
+ ),
+ funds: [],
+ },
+ };
+
+ log('Signing and broadcasting transaction...');
+
+ // Sign and broadcast both messages in one transaction
+ const result = await client.signAndBroadcast(
+ userAddress,
+ [authzMsg, registerMsg],
+ 'auto',
+ 'Register for Telegram Payments'
+ );
+
+ log(`✅ Transaction successful! Hash: ${result.transactionHash}`);
+
+ return result;
+ } catch (error) {
+ log(`❌ Registration failed: ${(error as Error).message}`, 'error');
+ throw error;
+ }
+}
+
+/**
+ * Update authz grant limit
+ */
+export async function updateAuthzLimit(
+ client: ISigningCosmWasmClient,
+ params: {
+ userAddress: string;
+ contractAddress: string;
+ newLimit: string; // in NTRN
+ denom?: string;
+ }
+) {
+ const { userAddress, contractAddress, newLimit, denom = 'untrn' } = params;
+
+ log(`Updating authz limit to ${newLimit} NTRN`);
+
+ const spendLimitMicro = (parseFloat(newLimit) * 1_000_000).toString();
+
+ try {
+ // Revoke old grant first
+ const revokeMsg = {
+ typeUrl: '/cosmos.authz.v1beta1.MsgRevoke',
+ value: {
+ granter: userAddress,
+ grantee: contractAddress,
+ msgTypeUrl: '/cosmos.bank.v1beta1.MsgSend',
+ },
+ };
+
+ // Create new grant
+ const grantMsg = createAuthzGrantMessage({
+ granter: userAddress,
+ grantee: contractAddress,
+ spendLimit: spendLimitMicro,
+ denom,
+ });
+
+ log('Signing and broadcasting limit update...');
+
+ const result = await client.signAndBroadcast(
+ userAddress,
+ [revokeMsg, grantMsg],
+ 'auto',
+ 'Update spending limit'
+ );
+
+ log(`✅ Limit updated! Hash: ${result.transactionHash}`);
+
+ return result;
+ } catch (error) {
+ log(`❌ Limit update failed: ${(error as Error).message}`, 'error');
+ throw error;
+ }
+}
+
+/**
+ * Revoke authz grant
+ */
+export async function revokeAuthz(
+ client: ISigningCosmWasmClient,
+ params: {
+ userAddress: string;
+ contractAddress: string;
+ }
+) {
+ const { userAddress, contractAddress } = params;
+
+ log('Revoking authz grant...');
+
+ try {
+ const revokeMsg = {
+ typeUrl: '/cosmos.authz.v1beta1.MsgRevoke',
+ value: {
+ granter: userAddress,
+ grantee: contractAddress,
+ msgTypeUrl: '/cosmos.bank.v1beta1.MsgSend',
+ },
+ };
+
+ const result = await client.signAndBroadcast(
+ userAddress,
+ [revokeMsg],
+ 'auto',
+ 'Revoke authorization'
+ );
+
+ log(`✅ Authorization revoked! Hash: ${result.transactionHash}`);
+
+ return result;
+ } catch (error) {
+ log(`❌ Revoke failed: ${(error as Error).message}`, 'error');
+ throw error;
+ }
+}
diff --git a/frontend/src/services/keplr.ts b/frontend/src/services/keplr.ts
index 5679573..3d57fda 100644
--- a/frontend/src/services/keplr.ts
+++ b/frontend/src/services/keplr.ts
@@ -3,12 +3,11 @@
* Handles connection to Keplr browser extension
*/
-import type { Keplr } from "@keplr-wallet/types";
import { toBase64 } from "@cosmjs/encoding";
declare global {
interface Window {
- keplr?: Keplr;
+ keplr?: any;
}
}
diff --git a/frontend/src/services/signer.ts b/frontend/src/services/signer.ts
new file mode 100644
index 0000000..9780c17
--- /dev/null
+++ b/frontend/src/services/signer.ts
@@ -0,0 +1,223 @@
+/**
+ * Signer Service
+ * Utilities for creating CosmWasm signing clients from different wallet sources
+ */
+
+import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing';
+import { getSigningCosmWasmClient, type ISigningCosmWasmClient } from '../contracts/baseClient';
+import { getCurrentSession, initWalletConnect } from './walletconnect';
+import type { SessionTypes } from '@walletconnect/types';
+import type { AccountData, OfflineDirectSigner } from '@cosmjs/proto-signing';
+import { log } from '../debug';
+
+export const DEFAULT_RPC_ENDPOINT = 'https://neutron-rpc.publicnode.com:443';
+export const DEFAULT_CHAIN_ID = 'neutron-1';
+export const DEFAULT_PREFIX = 'neutron';
+
+/**
+ * Create a signing client from a mnemonic
+ */
+export async function createSignerFromMnemonic(
+ mnemonic: string,
+ rpcEndpoint: string = DEFAULT_RPC_ENDPOINT,
+ prefix: string = DEFAULT_PREFIX
+): Promise<{ client: ISigningCosmWasmClient; address: string }> {
+ // Create wallet from mnemonic
+ const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix });
+
+ // Get the first account
+ const accounts = await wallet.getAccounts();
+ const account = accounts[0];
+
+ // Create signing client
+ const client = getSigningCosmWasmClient(wallet as any, rpcEndpoint);
+
+ return {
+ client,
+ address: account.address,
+ };
+}
+
+/**
+ * Create a signing client from Keplr wallet
+ */
+export async function createSignerFromKeplr(
+ chainId: string = DEFAULT_CHAIN_ID,
+ rpcEndpoint: string = DEFAULT_RPC_ENDPOINT
+): Promise<{ client: ISigningCosmWasmClient; address: string }> {
+ if (!window.keplr) {
+ throw new Error('Keplr extension not found. Please install Keplr from https://www.keplr.app/');
+ }
+
+ // Enable Keplr for the chain
+ await window.keplr.enable(chainId);
+
+ // Get the offline signer
+ const offlineSigner = window.keplr.getOfflineSigner(chainId);
+
+ // Get the first account
+ const accounts = await offlineSigner.getAccounts();
+ const account = accounts[0];
+
+ // Create signing client
+ const client = getSigningCosmWasmClient(offlineSigner as any, rpcEndpoint);
+
+ return {
+ client,
+ address: account.address,
+ };
+}
+
+/**
+ * Create a WalletConnect offline signer
+ */
+class WalletConnectSigner implements OfflineDirectSigner {
+ private session: SessionTypes.Struct;
+ private chainId: string;
+
+ constructor(
+ session: SessionTypes.Struct,
+ chainId: string
+ ) {
+ this.session = session;
+ this.chainId = chainId;
+ }
+
+ async getAccounts(): Promise {
+ log('WalletConnectSigner.getAccounts() called');
+ const cosmosNamespace = this.session.namespaces.cosmos;
+ if (!cosmosNamespace || !cosmosNamespace.accounts || cosmosNamespace.accounts.length === 0) {
+ throw new Error('No Cosmos accounts found in WalletConnect session');
+ }
+
+ log(`Found ${cosmosNamespace.accounts.length} accounts in session`);
+ // Parse account (format: "cosmos:neutron-1:neutron1abc...")
+ const accountString = cosmosNamespace.accounts[0];
+ log(`Parsing account string: ${accountString}`);
+ const addressParts = accountString.split(':');
+ const address = addressParts[2];
+ log(`Parsed address: ${address}`);
+
+ // Use a valid dummy compressed secp256k1 pubkey (33 bytes starting with 0x02)
+ // The real pubkey will be provided by the wallet during signing
+ const pubkey = new Uint8Array(33);
+ pubkey[0] = 0x02; // Valid compressed key marker
+ log('Using valid compressed dummy pubkey (real key provided during signing)');
+
+ log(`Returning account: ${address}`);
+ return [{
+ address,
+ pubkey,
+ algo: 'secp256k1' as const,
+ }];
+ }
+
+ async signDirect(signerAddress: string, signDoc: any): Promise {
+ const signClient = await initWalletConnect();
+
+ // Convert signDoc to the format expected by WalletConnect
+ const signDocJson = {
+ chainId: signDoc.chainId,
+ accountNumber: signDoc.accountNumber.toString(),
+ authInfoBytes: Array.from(signDoc.authInfoBytes),
+ bodyBytes: Array.from(signDoc.bodyBytes),
+ };
+
+ const result = await signClient.request<{
+ signed: {
+ accountNumber: string;
+ authInfoBytes: number[];
+ bodyBytes: number[];
+ };
+ signature: {
+ pub_key: any;
+ signature: string;
+ };
+ }>({
+ topic: this.session.topic,
+ chainId: `cosmos:${this.chainId}`,
+ request: {
+ method: 'cosmos_signDirect',
+ params: {
+ signerAddress,
+ signDoc: signDocJson,
+ },
+ },
+ });
+
+ return {
+ signed: {
+ ...signDoc,
+ accountNumber: BigInt(result.signed.accountNumber),
+ authInfoBytes: new Uint8Array(result.signed.authInfoBytes),
+ bodyBytes: new Uint8Array(result.signed.bodyBytes),
+ },
+ signature: {
+ pub_key: result.signature.pub_key,
+ signature: result.signature.signature,
+ },
+ };
+ }
+}
+
+/**
+ * Create a signing client from WalletConnect session
+ */
+export async function createSignerFromWalletConnect(
+ chainId: string = DEFAULT_CHAIN_ID,
+ rpcEndpoint: string = DEFAULT_RPC_ENDPOINT
+): Promise<{ client: ISigningCosmWasmClient; address: string }> {
+ log('createSignerFromWalletConnect called');
+
+ // IMPORTANT: Initialize WalletConnect to restore sessions from storage
+ log('Initializing WalletConnect to restore session...');
+ await initWalletConnect();
+
+ const session = getCurrentSession();
+ log(`Current WalletConnect session: ${session ? 'found' : 'not found'}`);
+
+ if (!session) {
+ throw new Error('No active WalletConnect session. Please connect your wallet first.');
+ }
+
+ log('Creating WalletConnect signer...');
+ // Create WalletConnect signer
+ const signer = new WalletConnectSigner(session, chainId);
+
+ log('Getting accounts from signer...');
+ // Get accounts
+ const accounts = await signer.getAccounts();
+ const account = accounts[0];
+ log(`Account address: ${account.address}`);
+
+ log('Creating signing client...');
+ // Create signing client
+ const client = getSigningCosmWasmClient(signer as any, rpcEndpoint);
+
+ log('✅ WalletConnect signing client created successfully');
+ return {
+ client,
+ address: account.address,
+ };
+}
+
+/**
+ * Prompt user to enter mnemonic via Telegram
+ * This is useful when you need the mnemonic but don't have it stored
+ */
+export function promptForMnemonic(message: string = 'Please enter your 24-word mnemonic'): Promise {
+ return new Promise((resolve, reject) => {
+ const mnemonic = prompt(message);
+ if (mnemonic && mnemonic.trim()) {
+ const words = mnemonic.trim().split(/\s+/);
+ if (words.length === 24) {
+ resolve(mnemonic.trim());
+ } else {
+ reject(new Error('Invalid mnemonic: must be 24 words'));
+ }
+ } else {
+ reject(new Error('No mnemonic provided'));
+ }
+ });
+}
+
diff --git a/frontend/src/services/storage.ts b/frontend/src/services/storage.ts
index d7358dd..8340507 100644
--- a/frontend/src/services/storage.ts
+++ b/frontend/src/services/storage.ts
@@ -4,6 +4,10 @@
*/
const ACCOUNT_KEY = "telegram_payments_account";
+const MNEMONIC_KEY = "telegram_payments_mnemonic";
+const WALLET_TYPE_KEY = "telegram_payments_wallet_type";
+
+export type WalletType = 'local' | 'keplr' | 'walletconnect';
export interface StoredAccount {
address: string;
@@ -48,4 +52,58 @@ export function hasAccount(): boolean {
*/
export function clearAccount(): void {
localStorage.removeItem(ACCOUNT_KEY);
+ localStorage.removeItem(MNEMONIC_KEY);
+ localStorage.removeItem(WALLET_TYPE_KEY);
+}
+
+/**
+ * Save mnemonic to local storage
+ * WARNING: This stores the mnemonic in plain text in localStorage.
+ * Only use this if you understand the security implications.
+ */
+export function saveMnemonic(mnemonic: string): void {
+ localStorage.setItem(MNEMONIC_KEY, mnemonic);
+}
+
+/**
+ * Get stored mnemonic
+ * WARNING: Returns the mnemonic in plain text.
+ */
+export function getStoredMnemonic(): string | null {
+ return localStorage.getItem(MNEMONIC_KEY);
+}
+
+/**
+ * Clear stored mnemonic
+ */
+export function clearMnemonic(): void {
+ localStorage.removeItem(MNEMONIC_KEY);
+}
+
+/**
+ * Check if mnemonic exists
+ */
+export function hasMnemonic(): boolean {
+ return getStoredMnemonic() !== null;
+}
+
+/**
+ * Save wallet type
+ */
+export function saveWalletType(walletType: WalletType): void {
+ localStorage.setItem(WALLET_TYPE_KEY, walletType);
+}
+
+/**
+ * Get stored wallet type
+ */
+export function getStoredWalletType(): WalletType | null {
+ return localStorage.getItem(WALLET_TYPE_KEY) as WalletType | null;
+}
+
+/**
+ * Clear wallet type
+ */
+export function clearWalletType(): void {
+ localStorage.removeItem(WALLET_TYPE_KEY);
}
diff --git a/frontend/src/services/walletconnect.ts b/frontend/src/services/walletconnect.ts
index a349aba..4e3a6c7 100644
--- a/frontend/src/services/walletconnect.ts
+++ b/frontend/src/services/walletconnect.ts
@@ -5,6 +5,7 @@
import SignClient from "@walletconnect/sign-client";
import type { SessionTypes } from "@walletconnect/types";
+import { log } from "../debug";
export interface WalletConnectAccount {
address: string;
@@ -42,8 +43,12 @@ export async function initWalletConnect(): Promise {
// Check for existing sessions on init
const sessions = signClient.session.getAll();
+ log(`Found ${sessions.length} existing WalletConnect sessions`);
if (sessions.length > 0) {
currentSession = sessions[sessions.length - 1]; // Use most recent session
+ log(`Restored WalletConnect session: ${currentSession.peer.metadata.name}`);
+ log(` Topic: ${currentSession.topic.substring(0, 16)}...`);
+ log(` Accounts: ${currentSession.namespaces.cosmos?.accounts?.length || 0}`);
}
return signClient;
diff --git a/frontend/src/utils/telegram.ts b/frontend/src/utils/telegram.ts
index 2db87a2..4a9fd0b 100644
--- a/frontend/src/utils/telegram.ts
+++ b/frontend/src/utils/telegram.ts
@@ -41,12 +41,12 @@ export function showPopup(
params: {
title?: string;
message: string;
- buttons?: Array<{ id?: string; type?: string; text?: string }>;
+ buttons?: Array<{ id?: string; type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive'; text?: string }>;
},
callback?: (buttonId: string) => void
): void {
if (WebApp.isVersionAtLeast('6.2')) {
- WebApp.showPopup(params, callback);
+ WebApp.showPopup(params as any, callback ? (id) => callback(id || 'ok') : undefined);
} else {
// Fallback to browser alert
const message = params.title ? `${params.title}\n\n${params.message}` : params.message;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 70f6a79..e40423c 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -5,6 +5,21 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
- allowedHosts: ["c9ee62ffcdf5.ngrok-free.app"],
+ allowedHosts: ["49c9ea1666e3.ngrok-free.app"],
+ },
+ define: {
+ global: "globalThis",
+ },
+ resolve: {
+ alias: {
+ buffer: "buffer",
+ },
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ define: {
+ global: "globalThis",
+ },
+ },
},
});