Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENG-5120] Permissions support for sats-connect #171

Merged
merged 11 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion example/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
project: ['./tsconfig.app.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
plugins: ['react-refresh', 'prettier'],
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
Expand Down
22 changes: 18 additions & 4 deletions example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion example/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "dapp-cookie-cutter",
"private": false,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"repository": {
"type": "git",
Expand Down Expand Up @@ -37,6 +37,7 @@
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.4.40",
Expand Down
151 changes: 132 additions & 19 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { Container, createTheme, MantineProvider, Stack } from '@mantine/core';
import '@mantine/core/styles.css';
import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
import { createContext, useCallback, useContext, useEffect, useMemo } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
createBrowserRouter,
createRoutesFromElements,
Link,
Outlet,
Route,
RouterProvider,
useNavigate,
} from 'react-router-dom';
import Wallet, { AddressPurpose, BitcoinNetworkType, type Address } from 'sats-connect';
import Wallet, {
AddressPurpose,
BitcoinNetworkType,
RpcErrorCode,
type Address,
} from 'sats-connect';
import { Button, ConnectButtonsContainer, Header, Logo } from './App.styles';
import { GetAccounts } from './components/bitcoin/GetAccounts';
import { GetBtcBalance } from './components/bitcoin/GetBtcBalance';
Expand All @@ -30,16 +36,19 @@ import { SendSip10 } from './components/stacks/SendSip10';
import { SendStx } from './components/stacks/SendStx';
import { SignTransaction } from './components/stacks/SignTransaction.tsx';
import TransferRunes from './components/transferRunes/index.tsx';
import { GetPermissions } from './components/wallet/GetPermissions.tsx';
import { WalletType } from './components/wallet/WalletType';
import { useLocalStorage } from './hooks';
import { CollapseDesktop } from './layouts/CollapseDesktop';

const ConnectionContext = createContext<{
accountId: string | null;
network: BitcoinNetworkType;
btcAddressInfo: Address[];
stxAddressInfo: Address[];
onDisconnect: () => void;
}>({
accountId: null,
network: BitcoinNetworkType.Mainnet,
btcAddressInfo: [],
stxAddressInfo: [],
Expand All @@ -52,37 +61,66 @@ const useConnectionContext = () => useContext(ConnectionContext);

const whiteListedPaths = ['/mobile-universal-link'];

function AppWithProviders({ children }: React.PropsWithChildren<{}>) {
function AppWithProviders({ children }: React.PropsWithChildren) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [network, setNetwork] = useLocalStorage<BitcoinNetworkType>(
'network',
BitcoinNetworkType.Mainnet,
);
const [btcAddressInfo, setBtcAddressInfo] = useLocalStorage<Address[]>('btc-addresses', []);
const [stxAddressInfo, setStxAddressInfo] = useLocalStorage<Address[]>('stx-addresses', []);
const [accountId, setAccountId] = useState<string | null>(null);
const [btcAddressInfo, setBtcAddressInfo] = useState<Address[]>([]);
const [stxAddressInfo, setStxAddressInfo] = useState<Address[]>([]);
const [isLoading, setIsLoading] = useState(true);

const isConnected = btcAddressInfo.length + stxAddressInfo.length > 0;

const isWhiteListedPath = whiteListedPaths.includes(window.location.pathname);

const clearAppData = useCallback(() => {
setAccountId(null);
setBtcAddressInfo([]);
setStxAddressInfo([]);
queryClient.clear();
}, [queryClient, setBtcAddressInfo, setStxAddressInfo]);
}, [queryClient, setBtcAddressInfo, setStxAddressInfo, setAccountId]);

const onDisconnect = useCallback(() => {
(async () => {
await Wallet.disconnect();
clearAppData();
})().catch(console.error);
}, [queryClient, setBtcAddressInfo, setStxAddressInfo]);
}, [clearAppData]);

useEffect(() => {
if (btcAddressInfo.length < 1) return;

const removeListenerAccountChange = Wallet.addListener('accountChange', (ev) => {
console.log('The account has changed.', ev);

// Attempt to get the new account details.
(async () => {
const res = await Wallet.request('wallet_getAccount', undefined);

if (res.status === 'error' && res.error.code === (RpcErrorCode.ACCESS_DENIED as number)) {
// The app doesn't have permission to read from this account. Clear
// state and redirect to home page, where the user is prompted to
// connect.
clearAppData();
navigate('/');
return;
}

if (res.status === 'error') {
console.error('Received unexpected error while getting account details.');
console.error(res);
return;
}

const btcAddresses = res.result.addresses.filter((a) =>
[AddressPurpose.Ordinals, AddressPurpose.Payment].includes(a.purpose),
);
setBtcAddressInfo(btcAddresses);
setStxAddressInfo(res.result.addresses.filter((a) => a.purpose === AddressPurpose.Stacks));
setAccountId(res.result.id);
})().catch(console.error);
});

const removeListenerDisconnect = Wallet.addListener('disconnect', (ev) => {
Expand All @@ -94,9 +132,43 @@ function AppWithProviders({ children }: React.PropsWithChildren<{}>) {
removeListenerAccountChange();
removeListenerDisconnect();
};
}, [btcAddressInfo]);
}, [clearAppData, navigate]);

// On initial load, check if the app has the perms it needs and load necessary
// data.
useEffect(() => {
(async function () {
const res = await Wallet.request('wallet_getAccount', undefined);

if (res.status === 'error' && res.error.code === (RpcErrorCode.ACCESS_DENIED as number)) {
// The app doesn't have permission to read from this account. Clear
// state and redirect to home page, where the user is prompted to
// connect.
clearAppData();
navigate('/');
setIsLoading(false);
return;
}

if (res.status === 'error') {
console.error('Received unexpected error while getting account details.');
console.error(res);
setIsLoading(false);
return;
}

const btcAddresses = res.result.addresses.filter((a) =>
[AddressPurpose.Ordinals, AddressPurpose.Payment].includes(a.purpose),
);
setBtcAddressInfo(btcAddresses);
setStxAddressInfo(res.result.addresses.filter((a) => a.purpose === AddressPurpose.Stacks));
setAccountId(res.result.id);

setIsLoading(false);
})().catch(console.error);
}, [clearAppData, navigate]);

const onConnectLegacy = useCallback(() => {
const handleLegacyConnectWithGetAccounts = useCallback(() => {
(async () => {
const response = await Wallet.request('getAccounts', {
purposes: [AddressPurpose.Payment, AddressPurpose.Ordinals, AddressPurpose.Stacks],
Expand All @@ -109,7 +181,7 @@ function AppWithProviders({ children }: React.PropsWithChildren<{}>) {
})().catch(console.error);
}, [setBtcAddressInfo, setStxAddressInfo]);

const onConnect = useCallback(() => {
const handleLegacyConnectWithRequestPermissions = useCallback(() => {
(async () => {
const res = await Wallet.request('wallet_requestPermissions', undefined);
if (res.status === 'error') {
Expand Down Expand Up @@ -138,11 +210,33 @@ function AppWithProviders({ children }: React.PropsWithChildren<{}>) {
})().catch(console.error);
}, [setBtcAddressInfo, setStxAddressInfo]);

const handleConnect = useCallback(() => {
(async () => {
const res = await Wallet.request('wallet_connect', {
message: 'Cool app wants to know your addresses!',
addresses: [AddressPurpose.Payment, AddressPurpose.Ordinals, AddressPurpose.Stacks],
});
if (res.status === 'error') {
console.error('Error connecting to wallet, details in terminal.');
console.error(res);
return;
}
const btcAddresses = res.result.addresses.filter((a) =>
[AddressPurpose.Ordinals, AddressPurpose.Payment].includes(a.purpose),
);
setBtcAddressInfo(btcAddresses);
setStxAddressInfo(res.result.addresses.filter((a) => a.purpose === AddressPurpose.Stacks));
setAccountId(res.result.id);
})().catch(console.error);
}, [setBtcAddressInfo, setStxAddressInfo]);

const connectionContextValue = useMemo(
() => ({ network, btcAddressInfo, stxAddressInfo, onDisconnect }),
[network, btcAddressInfo, stxAddressInfo, onDisconnect],
() => ({ network, btcAddressInfo, stxAddressInfo, onDisconnect, accountId }),
[network, btcAddressInfo, stxAddressInfo, onDisconnect, accountId],
);

if (isLoading) return <div>Loading...</div>;

if (!isConnected && !isWhiteListedPath) {
return (
<Container>
Expand All @@ -151,8 +245,13 @@ function AppWithProviders({ children }: React.PropsWithChildren<{}>) {
<NetworkSelector network={network} setNetwork={setNetwork} />
<p>Click the button to connect your wallet</p>
<ConnectButtonsContainer>
<Button onClick={onConnect}>Connect Account</Button>
<Button onClick={onConnectLegacy}>Connect (Legacy)</Button>
<Button onClick={handleConnect}>Connect</Button>
<Button onClick={handleLegacyConnectWithRequestPermissions}>
wallet_requestPermissions
</Button>
<Button onClick={handleLegacyConnectWithGetAccounts}>
Connect (Legacy getAccounts)
</Button>
</ConnectButtonsContainer>
</Header>
</Container>
Expand All @@ -168,29 +267,39 @@ function AppWithProviders({ children }: React.PropsWithChildren<{}>) {

// TODO move to pages or routes.tsx
const WalletMethods = () => {
const { network, btcAddressInfo, stxAddressInfo, onDisconnect } = useConnectionContext();
const { network, btcAddressInfo, stxAddressInfo, onDisconnect, accountId } =
useConnectionContext();

if (!accountId) return <div>Error: no account ID set.</div>;

return (
<>
<div>
<Logo src="/sats-connect.svg" alt="SatsConnect" />
</div>
<AddressDisplay
accountId={accountId}
network={network}
addresses={[...btcAddressInfo, ...stxAddressInfo]}
onDisconnect={onDisconnect}
/>
<GetAddresses />
<WalletType />
<GetPermissions />
<GetAccounts />
</>
);
};

const BitcoinMethods = () => {
const { network, btcAddressInfo, onDisconnect } = useConnectionContext();
const { network, btcAddressInfo, onDisconnect, accountId } = useConnectionContext();

if (!accountId) return <div>Error: no account ID set.</div>;

return (
<>
<AddressDisplay
accountId={accountId}
network={network}
addresses={[...btcAddressInfo]}
onDisconnect={onDisconnect}
Expand All @@ -209,10 +318,14 @@ const BitcoinMethods = () => {
};

const StacksMethods = () => {
const { network, stxAddressInfo, onDisconnect } = useConnectionContext();
const { network, stxAddressInfo, onDisconnect, accountId } = useConnectionContext();

if (!accountId) return <div>Error: no account ID set.</div>;

return (
<>
<AddressDisplay
accountId={accountId}
network={network}
addresses={[...stxAddressInfo]}
onDisconnect={onDisconnect}
Expand Down
6 changes: 5 additions & 1 deletion example/src/components/AddressDisplay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import { Address, BitcoinNetworkType } from 'sats-connect';
import { Button, Card, H4 } from '../../App.styles';

interface Props {
accountId: string;
network: BitcoinNetworkType;
addresses: Address[];
onDisconnect: () => void;
}

export const AddressDisplay = ({ network, addresses, onDisconnect }: Props) => {
export const AddressDisplay = ({ accountId, network, addresses, onDisconnect }: Props) => {
return (
<Card>
<h3>Connected Addresses - ({network})</h3>
<div>
<H4>Account: {accountId}</H4>
</div>
{addresses.map((address) => (
<div key={address.purpose}>
<H4>{address.purpose}</H4>
Expand Down
Loading
Loading