Skip to content

Commit

Permalink
[ENG-5120] Permissions support for sats-connect (#171)
Browse files Browse the repository at this point in the history
* Add disconnect events

* Update example app to handle disconnection events

* wip

* WIP

* Use new connect method

* wip

* update core version

* update provider-config pkg

* fix type error

* update import

---------

Co-authored-by: Mahmoud Aboelenein <[email protected]>
  • Loading branch information
aryzing and m-aboelenein authored Oct 31, 2024
1 parent e1e6a02 commit c047204
Show file tree
Hide file tree
Showing 10 changed files with 261 additions and 81 deletions.
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

0 comments on commit c047204

Please sign in to comment.