Skip to content

Commit

Permalink
[feat] Native privy auth (#51)
Browse files Browse the repository at this point in the history
* native privy integration

* working privy native login with 1193 provider

* refactor & jsdoc type

* import fix

* remove console.log
  • Loading branch information
coffeexcoin authored Oct 14, 2024
1 parent 7b102d3 commit ab774ae
Show file tree
Hide file tree
Showing 9 changed files with 2,190 additions and 73 deletions.
7 changes: 7 additions & 0 deletions packages/agw-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
"import": "./dist/esm/exports/connectors.js",
"require": "./dist/cjs/exports/connectors.js"
},
"./privy": {
"types": "./dist/types/exports/privy.d.ts",
"import": "./dist/esm/exports/privy.js",
"require": "./dist/cjs/exports/privy.js"
},
"./package.json": "./package.json"
},
"files": [
Expand All @@ -41,6 +46,7 @@
"peerDependencies": {
"@abstract-foundation/agw-client": "workspace:*",
"@privy-io/cross-app-connect": "^0.0.3-beta-20240913012159",
"@privy-io/react-auth": "^1.88.3",
"@tanstack/react-query": "^5",
"react": ">=18",
"typescript": ">=5.0.4",
Expand All @@ -50,6 +56,7 @@
"devDependencies": {
"@abstract-foundation/agw-client": "workspace:*",
"@privy-io/cross-app-connect": "^0.0.3-beta-20240913012159",
"@privy-io/react-auth": "^1.88.3",
"@rainbow-me/rainbowkit": "^2.1.6",
"@tanstack/query-core": "^5.56.2",
"@types/react": ">=18.3.1",
Expand Down
1 change: 1 addition & 0 deletions packages/agw-react/src/abstractWalletConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function abstractWalletConnector(
...rkDetails,
getProvider: getAbstractProvider,
type: 'abstract',
id: 'xyz.abs.privy',
};
return abstractConnector;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/agw-react/src/exports/privy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AbstractPrivyProvider } from '../privy/abstractPrivyProvider.js';
export { useAbstractPrivyLogin } from '../privy/useAbstractPrivyLogin.js';
2 changes: 1 addition & 1 deletion packages/agw-react/src/hooks/useLoginWithAbstract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const useLoginWithAbstract = (): AbstractLogin => {
const { disconnect } = useDisconnect();

const login = useCallback(() => {
const connector = connectors.find((c) => c.type === 'abstract');
const connector = connectors.find((c) => c.id === 'xyz.abs.privy');
if (!connector) {
throw new Error('Abstract connector not found');
}
Expand Down
53 changes: 53 additions & 0 deletions packages/agw-react/src/privy/abstractPrivyProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { PrivyProvider, type PrivyProviderProps } from '@privy-io/react-auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { type Transport } from 'viem';
import { abstractTestnet } from 'viem/chains';
import { createConfig, http, WagmiProvider } from 'wagmi';

import { InjectWagmiConnector } from './injectWagmiConnector.js';

/**
* Configuration options for the AbstractPrivyProvider.
* @interface AgwPrivyProviderProps
* @extends PrivyProviderProps
* @property {boolean} testnet - Whether to use abstract testnet, defaults to false.
* @property {Transport} transport - Optional transport to use, defaults to standard http.
*/
interface AgwPrivyProviderProps extends PrivyProviderProps {
testnet?: boolean;
transport?: Transport;
}

export const AbstractPrivyProvider = ({
testnet = false,
transport,
...props
}: AgwPrivyProviderProps) => {
const chain = testnet ? abstractTestnet : abstractTestnet;

const wagmiConfig = createConfig({
chains: [chain],
ssr: true,
connectors: [],
transports: {
[chain.id]: transport ?? http(),
},
multiInjectedProviderDiscovery: false,
});
const queryClient = new QueryClient();
return (
<PrivyProvider {...props}>
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<InjectWagmiConnector
testnet={testnet}
transport={transport ?? http()}
>
{props.children}
</InjectWagmiConnector>
</QueryClientProvider>
</WagmiProvider>
</PrivyProvider>
);
};
51 changes: 51 additions & 0 deletions packages/agw-react/src/privy/injectWagmiConnector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Fragment, useEffect, useState } from 'react';
import React from 'react';
import type { EIP1193Provider, Transport } from 'viem';
import { useConfig, useReconnect } from 'wagmi';
import { injected } from 'wagmi/connectors';

import { usePrivyCrossAppProvider } from './usePrivyCrossAppProvider';

interface InjectWagmiConnectorProps extends React.PropsWithChildren {
testnet: boolean;
transport: Transport;
}

export const InjectWagmiConnector = (props: InjectWagmiConnectorProps) => {
const { testnet, transport, children } = props;

const config = useConfig();
const { reconnect } = useReconnect();
const { provider, ready } = usePrivyCrossAppProvider({ testnet, transport });
const [isSetup, setIsSetup] = useState(false);

useEffect(() => {
const setup = async (provider: EIP1193Provider) => {
const wagmiConnector = injected({
target: {
provider,
id: 'xyz.abs.privy',
name: 'Abstract Global Wallet',
icon: '',
},
});

const connector = config._internal.connectors.setup(wagmiConnector);
await config.storage?.setItem('recentConnectorId', 'xyz.abs.privy');
config._internal.connectors.setState([connector]);

return connector;
};

if (ready && !isSetup) {
setup(provider).then((connector) => {
if (connector) {
reconnect({ connectors: [connector] });
setIsSetup(true);
}
});
}
}, [provider, ready]);

return <Fragment>{children}</Fragment>;
};
13 changes: 13 additions & 0 deletions packages/agw-react/src/privy/useAbstractPrivyLogin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useCrossAppAccounts } from '@privy-io/react-auth';

import { AGW_APP_ID } from '../constants.js';

export const useAbstractPrivyLogin = () => {
const { loginWithCrossAppAccount, linkCrossAppAccount } =
useCrossAppAccounts();

return {
login: () => loginWithCrossAppAccount({ appId: AGW_APP_ID }),
link: () => linkCrossAppAccount({ appId: AGW_APP_ID }),
};
};
209 changes: 209 additions & 0 deletions packages/agw-react/src/privy/usePrivyCrossAppProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { transformEIP1193Provider } from '@abstract-foundation/agw-client';
import {
type CrossAppAccount,
type SignTypedDataParams,
useCrossAppAccounts,
usePrivy,
type User,
} from '@privy-io/react-auth';
import { randomBytes } from 'crypto';
import { useCallback, useMemo } from 'react';
import {
type Address,
createPublicClient,
type EIP1193Provider,
type EIP1193RequestFn,
type EIP1474Methods,
fromHex,
http,
type RpcSchema,
type Transport,
} from 'viem';
import { abstractTestnet } from 'viem/chains';

const AGW_APP_ID = 'cm04asygd041fmry9zmcyn5o5';

type RpcMethodNames<rpcSchema extends RpcSchema> =
rpcSchema[keyof rpcSchema] extends { Method: string }
? rpcSchema[keyof rpcSchema]['Method']
: never;
type EIP1474MethodNames = RpcMethodNames<EIP1474Methods>;

interface UsePrivyCrossAppEIP1193Props {
testnet?: boolean;
transport?: Transport;
}

export const usePrivyCrossAppProvider = ({
testnet = false,
transport = http(),
}: UsePrivyCrossAppEIP1193Props) => {
const chain = testnet ? abstractTestnet : abstractTestnet;

const {
loginWithCrossAppAccount,
linkCrossAppAccount,
// sendTransaction, TBD
signMessage,
signTypedData,
} = useCrossAppAccounts();
const { user, authenticated, ready } = usePrivy();

const passthroughMethods = {
web3_clientVersion: true,
web3_sha3: true,
net_listening: true,
net_peerCount: true,
net_version: true,
eth_blobBaseFee: true,
eth_blockNumber: true,
eth_call: true,
eth_chainId: true,
eth_coinbase: true,
eth_estimateGas: true,
eth_feeHistory: true,
eth_gasPrice: true,
eth_getBalance: true,
eth_getBlockByHash: true,
eth_getBlockByNumber: true,
eth_getBlockTransactionCountByHash: true,
eth_getBlockTransactionCountByNumber: true,
eth_getCode: true,
eth_getFilterChanges: true,
eth_getFilterLogs: true,
eth_getLogs: true,
eth_getProof: true,
eth_getStorageAt: true,
eth_getTransactionByBlockHashAndIndex: true,
eth_getTransactionByBlockNumberAndIndex: true,
eth_getTransactionByHash: true,
eth_getTransactionCount: true,
eth_getTransactionReceipt: true,
eth_getUncleByBlockHashAndIndex: true,
eth_getUncleByBlockNumberAndIndex: true,
eth_getUncleCountByBlockHash: true,
eth_getUncleCountByBlockNumber: true,
eth_maxPriorityFeePerGas: true,
eth_newBlockFilter: true,
eth_newFilter: true,
eth_newPendingTransactionFilter: true,
eth_protocolVersion: true,
eth_sendRawTransaction: true,
eth_uninstallFilter: true,
};
const passthrough = (method: EIP1474MethodNames) =>
!!passthroughMethods[method];

const publicClient = createPublicClient({
chain,
transport,
});

const getAddressFromUser = (user: User | null): Address | undefined => {
if (!user) {
return undefined;
}
const crossAppAccount = user.linkedAccounts.find(
(account) =>
account.type === 'cross_app' && account.providerApp.id === AGW_APP_ID,
) as CrossAppAccount | undefined;

const address = crossAppAccount?.embeddedWallets?.[0]?.address;
return address ? (address as Address) : undefined;
};

const getAccounts = useCallback(
async (promptLogin: boolean) => {
if (!ready) {
return [];
}
let contextUser = user;
if (promptLogin) {
if (!contextUser && !authenticated) {
contextUser = await loginWithCrossAppAccount({
appId: AGW_APP_ID,
});
} else if (!contextUser && authenticated) {
contextUser = await linkCrossAppAccount({ appId: AGW_APP_ID });
}
}
const address = getAddressFromUser(contextUser);
return address ? [address] : [];
},
[user, authenticated, ready, loginWithCrossAppAccount, linkCrossAppAccount],
);

const eventListeners = new Map<string, ((...args: any[]) => void)[]>();

const handleRequest = useCallback(
async (request: any) => {
const { method, params } = request;
if (passthrough(method as EIP1474MethodNames)) {
return publicClient.request(request);
}

switch (method) {
case 'eth_requestAccounts': {
return await getAccounts(true);
}
case 'eth_accounts': {
return await getAccounts(false);
}
case 'wallet_switchEthereumChain':
// TODO: do we need to do anything here?
return null;
case 'wallet_revokePermissions':
// TODO: do we need to do anything here?
return null;
case 'eth_sendTransaction':
case 'eth_signTransaction':
// TODO: Implement
return randomBytes(32).toString('hex'); // fake tx hash
case 'eth_signTypedData_v4':
return await signTypedData(
JSON.parse(params[1]) as SignTypedDataParams,
{ address: params[0] },
);
case 'eth_sign':
throw new Error('eth_sign is unsafe and not supported');
case 'personal_sign': {
return await signMessage(fromHex(params[0], 'string'), {
address: params[1],
});
}
default:
throw new Error(`Unsupported request: ${method}`);
}
},
[passthrough, publicClient, getAccounts, signMessage],
);

const provider: EIP1193Provider = useMemo(() => {
return {
on: (event, listener) => {
eventListeners.set(event, [
...(eventListeners.get(event) ?? []),
listener,
]);
},
removeListener: (event, listener) => {
eventListeners.set(
event,
(eventListeners.get(event) ?? []).filter((l) => l !== listener),
);
},
request: handleRequest as EIP1193RequestFn<EIP1474Methods>,
};
}, [handleRequest]);

const wrappedProvider = transformEIP1193Provider({
chain,
provider,
transport,
});

return {
ready,
provider: wrappedProvider,
};
};
Loading

0 comments on commit ab774ae

Please sign in to comment.