metaplex/js/packages/common/src/contexts/connection.tsx

671 lines
17 KiB
TypeScript

import { sleep, useLocalStorageState } from '../utils/utils';
import {
Keypair,
BlockhashAndFeeCalculator,
clusterApiUrl,
Commitment,
Connection,
RpcResponseAndContext,
SignatureStatus,
SimulatedTransactionResponse,
Transaction,
TransactionInstruction,
TransactionSignature,
} from '@solana/web3.js';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { notify } from '../utils/notifications';
import { ExplorerLink } from '../components/ExplorerLink';
import { setProgramIds } from '../utils/ids';
import {
TokenInfo,
TokenListProvider,
ENV as ChainId,
} from '@solana/spl-token-registry';
export type ENV =
| 'mainnet-beta'
| 'mainnet-beta (Solana)'
| 'mainnet-beta (Serum)'
| 'testnet'
| 'devnet'
| 'localnet'
| 'lending';
export const ENDPOINTS = [
{
name: 'mainnet-beta' as ENV,
endpoint: 'https://api.metaplex.solana.com/',
ChainId: ChainId.MainnetBeta,
},
{
name: 'mainnet-beta (Solana)' as ENV,
endpoint: 'https://api.mainnet-beta.solana.com',
ChainId: ChainId.MainnetBeta,
},
{
name: 'mainnet-beta (Serum)' as ENV,
endpoint: 'https://solana-api.projectserum.com/',
ChainId: ChainId.MainnetBeta,
},
{
name: 'testnet' as ENV,
endpoint: clusterApiUrl('testnet'),
ChainId: ChainId.Testnet,
},
{
name: 'devnet' as ENV,
endpoint: clusterApiUrl('devnet'),
ChainId: ChainId.Devnet,
},
];
const DEFAULT = ENDPOINTS[0].endpoint;
const DEFAULT_SLIPPAGE = 0.25;
interface ConnectionConfig {
connection: Connection;
sendConnection: Connection;
endpoint: string;
slippage: number;
setSlippage: (val: number) => void;
env: ENV;
setEndpoint: (val: string) => void;
tokens: TokenInfo[];
tokenMap: Map<string, TokenInfo>;
}
const ConnectionContext = React.createContext<ConnectionConfig>({
endpoint: DEFAULT,
setEndpoint: () => {},
slippage: DEFAULT_SLIPPAGE,
setSlippage: (val: number) => {},
connection: new Connection(DEFAULT, 'recent'),
sendConnection: new Connection(DEFAULT, 'recent'),
env: ENDPOINTS[0].name,
tokens: [],
tokenMap: new Map<string, TokenInfo>(),
});
export function ConnectionProvider({ children = undefined as any }) {
const [endpoint, setEndpoint] = useLocalStorageState(
'connectionEndpoint',
ENDPOINTS[0].endpoint,
);
const [slippage, setSlippage] = useLocalStorageState(
'slippage',
DEFAULT_SLIPPAGE.toString(),
);
const connection = useMemo(
() => new Connection(endpoint, 'recent'),
[endpoint],
);
const sendConnection = useMemo(
() => new Connection(endpoint, 'recent'),
[endpoint],
);
const env =
ENDPOINTS.find(end => end.endpoint === endpoint)?.name || ENDPOINTS[0].name;
const [tokens, setTokens] = useState<TokenInfo[]>([]);
const [tokenMap, setTokenMap] = useState<Map<string, TokenInfo>>(new Map());
useEffect(() => {
// fetch token files
new TokenListProvider().resolve().then(container => {
const list = container
.excludeByTag('nft')
.filterByChainId(
ENDPOINTS.find(end => end.endpoint === endpoint)?.ChainId ||
ChainId.MainnetBeta,
)
.getList();
const knownMints = [...list].reduce((map, item) => {
map.set(item.address, item);
return map;
}, new Map<string, TokenInfo>());
setTokenMap(knownMints);
setTokens(list);
});
}, [env]);
setProgramIds(env);
// The websocket library solana/web3.js uses closes its websocket connection when the subscription list
// is empty after opening its first time, preventing subsequent subscriptions from receiving responses.
// This is a hack to prevent the list from every getting empty
useEffect(() => {
const id = connection.onAccountChange(
Keypair.generate().publicKey,
() => {},
);
return () => {
connection.removeAccountChangeListener(id);
};
}, [connection]);
useEffect(() => {
const id = connection.onSlotChange(() => null);
return () => {
connection.removeSlotChangeListener(id);
};
}, [connection]);
useEffect(() => {
const id = sendConnection.onAccountChange(
Keypair.generate().publicKey,
() => {},
);
return () => {
sendConnection.removeAccountChangeListener(id);
};
}, [sendConnection]);
useEffect(() => {
const id = sendConnection.onSlotChange(() => null);
return () => {
sendConnection.removeSlotChangeListener(id);
};
}, [sendConnection]);
return (
<ConnectionContext.Provider
value={{
endpoint,
setEndpoint,
slippage: parseFloat(slippage),
setSlippage: val => setSlippage(val.toString()),
connection,
sendConnection,
tokens,
tokenMap,
env,
}}
>
{children}
</ConnectionContext.Provider>
);
}
export function useConnection() {
return useContext(ConnectionContext).connection as Connection;
}
export function useSendConnection() {
return useContext(ConnectionContext)?.sendConnection;
}
export function useConnectionConfig() {
const context = useContext(ConnectionContext);
return {
endpoint: context.endpoint,
setEndpoint: context.setEndpoint,
env: context.env,
tokens: context.tokens,
tokenMap: context.tokenMap,
};
}
export function useSlippageConfig() {
const { slippage, setSlippage } = useContext(ConnectionContext);
return { slippage, setSlippage };
}
export const getErrorForTransaction = async (
connection: Connection,
txid: string,
) => {
// wait for all confirmation before geting transaction
await connection.confirmTransaction(txid, 'max');
const tx = await connection.getParsedConfirmedTransaction(txid);
const errors: string[] = [];
if (tx?.meta && tx.meta.logMessages) {
tx.meta.logMessages.forEach(log => {
const regex = /Error: (.*)/gm;
let m;
while ((m = regex.exec(log)) !== null) {
// This is necessary to avoid infinite loops with zero-width matches
if (m.index === regex.lastIndex) {
regex.lastIndex++;
}
if (m.length > 1) {
errors.push(m[1]);
}
}
});
}
return errors;
};
export enum SequenceType {
Sequential,
Parallel,
StopOnFailure,
}
export const sendTransactions = async (
connection: Connection,
wallet: any,
instructionSet: TransactionInstruction[][],
signersSet: Keypair[][],
sequenceType: SequenceType = SequenceType.Parallel,
commitment: Commitment = 'singleGossip',
successCallback: (txid: string, ind: number) => void = (txid, ind) => {},
failCallback: (reason: string, ind: number) => boolean = (txid, ind) => false,
block?: BlockhashAndFeeCalculator,
): Promise<number> => {
const unsignedTxns: Transaction[] = [];
if (!block) {
block = await connection.getRecentBlockhash(commitment);
}
for (let i = 0; i < instructionSet.length; i++) {
const instructions = instructionSet[i];
const signers = signersSet[i];
if (instructions.length === 0) {
continue;
}
let transaction = new Transaction();
instructions.forEach(instruction => transaction.add(instruction));
transaction.recentBlockhash = block.blockhash;
transaction.setSigners(
// fee payed by the wallet owner
wallet.publicKey,
...signers.map(s => s.publicKey),
);
if (signers.length > 0) {
transaction.partialSign(...signers);
}
unsignedTxns.push(transaction);
}
const signedTxns = await wallet.signAllTransactions(unsignedTxns);
const pendingTxns: Promise<{ txid: string; slot: number }>[] = [];
let breakEarlyObject = { breakEarly: false, i: 0 };
console.log(
'Signed txns length',
signedTxns.length,
'vs handed in length',
instructionSet.length,
);
for (let i = 0; i < signedTxns.length; i++) {
const signedTxnPromise = sendSignedTransaction({
connection,
signedTransaction: signedTxns[i],
});
signedTxnPromise
.then(({ txid, slot }) => {
successCallback(txid, i);
})
.catch(reason => {
failCallback(signedTxns[i], i);
if (sequenceType === SequenceType.StopOnFailure) {
breakEarlyObject.breakEarly = true;
breakEarlyObject.i = i;
}
});
if (sequenceType !== SequenceType.Parallel) {
try {
await signedTxnPromise;
} catch (e) {
console.log('Caught failure', e);
if (breakEarlyObject.breakEarly) {
console.log('Died on ', breakEarlyObject.i);
return breakEarlyObject.i; // Return the txn we failed on by index
}
}
} else {
pendingTxns.push(signedTxnPromise);
}
}
if (sequenceType !== SequenceType.Parallel) {
await Promise.all(pendingTxns);
}
return signedTxns.length;
};
export const sendTransaction = async (
connection: Connection,
wallet: any,
instructions: TransactionInstruction[],
signers: Keypair[],
awaitConfirmation = true,
commitment: Commitment = 'singleGossip',
includesFeePayer: boolean = false,
block?: BlockhashAndFeeCalculator,
) => {
let transaction = new Transaction();
instructions.forEach(instruction => transaction.add(instruction));
transaction.recentBlockhash = (
block || (await connection.getRecentBlockhash(commitment))
).blockhash;
if (includesFeePayer) {
transaction.setSigners(...signers.map(s => s.publicKey));
} else {
transaction.setSigners(
// fee payed by the wallet owner
wallet.publicKey,
...signers.map(s => s.publicKey),
);
}
if (signers.length > 0) {
transaction.partialSign(...signers);
}
if (!includesFeePayer) {
transaction = await wallet.signTransaction(transaction);
}
const rawTransaction = transaction.serialize();
let options = {
skipPreflight: true,
commitment,
};
const txid = await connection.sendRawTransaction(rawTransaction, options);
let slot = 0;
if (awaitConfirmation) {
const confirmation = await awaitTransactionSignatureConfirmation(
txid,
DEFAULT_TIMEOUT,
connection,
commitment,
);
if (!confirmation)
throw new Error('Timed out awaiting confirmation on transaction');
slot = confirmation?.slot || 0;
if (confirmation?.err) {
const errors = await getErrorForTransaction(connection, txid);
notify({
message: 'Transaction failed...',
description: (
<>
{errors.map(err => (
<div>{err}</div>
))}
<ExplorerLink address={txid} type="transaction" />
</>
),
type: 'error',
});
throw new Error(
`Raw transaction ${txid} failed (${JSON.stringify(status)})`,
);
}
}
return { txid, slot };
};
export const sendTransactionWithRetry = async (
connection: Connection,
wallet: any,
instructions: TransactionInstruction[],
signers: Keypair[],
commitment: Commitment = 'singleGossip',
includesFeePayer: boolean = false,
block?: BlockhashAndFeeCalculator,
beforeSend?: () => void,
) => {
let transaction = new Transaction();
instructions.forEach(instruction => transaction.add(instruction));
transaction.recentBlockhash = (
block || (await connection.getRecentBlockhash(commitment))
).blockhash;
if (includesFeePayer) {
transaction.setSigners(...signers.map(s => s.publicKey));
} else {
transaction.setSigners(
// fee payed by the wallet owner
wallet.publicKey,
...signers.map(s => s.publicKey),
);
}
if (signers.length > 0) {
transaction.partialSign(...signers);
}
if (!includesFeePayer) {
transaction = await wallet.signTransaction(transaction);
}
if (beforeSend) {
beforeSend();
}
const { txid, slot } = await sendSignedTransaction({
connection,
signedTransaction: transaction,
});
return { txid, slot };
};
export const getUnixTs = () => {
return new Date().getTime() / 1000;
};
const DEFAULT_TIMEOUT = 15000;
export async function sendSignedTransaction({
signedTransaction,
connection,
timeout = DEFAULT_TIMEOUT,
}: {
signedTransaction: Transaction;
connection: Connection;
sendingMessage?: string;
sentMessage?: string;
successMessage?: string;
timeout?: number;
}): Promise<{ txid: string; slot: number }> {
const rawTransaction = signedTransaction.serialize();
const startTime = getUnixTs();
let slot = 0;
const txid: TransactionSignature = await connection.sendRawTransaction(
rawTransaction,
{
skipPreflight: true,
},
);
console.log('Started awaiting confirmation for', txid);
let done = false;
(async () => {
while (!done && getUnixTs() - startTime < timeout) {
connection.sendRawTransaction(rawTransaction, {
skipPreflight: true,
});
await sleep(500);
}
})();
try {
const confirmation = await awaitTransactionSignatureConfirmation(
txid,
timeout,
connection,
'recent',
true,
);
if (!confirmation)
throw new Error('Timed out awaiting confirmation on transaction');
if (confirmation.err) {
console.error(confirmation.err);
throw new Error('Transaction failed: Custom instruction error');
}
slot = confirmation?.slot || 0;
} catch (err) {
console.error('Timeout Error caught', err);
if (err.timeout) {
throw new Error('Timed out awaiting confirmation on transaction');
}
let simulateResult: SimulatedTransactionResponse | null = null;
try {
simulateResult = (
await simulateTransaction(connection, signedTransaction, 'single')
).value;
} catch (e) {}
if (simulateResult && simulateResult.err) {
if (simulateResult.logs) {
for (let i = simulateResult.logs.length - 1; i >= 0; --i) {
const line = simulateResult.logs[i];
if (line.startsWith('Program log: ')) {
throw new Error(
'Transaction failed: ' + line.slice('Program log: '.length),
);
}
}
}
throw new Error(JSON.stringify(simulateResult.err));
}
// throw new Error('Transaction failed');
} finally {
done = true;
}
console.log('Latency', txid, getUnixTs() - startTime);
return { txid, slot };
}
async function simulateTransaction(
connection: Connection,
transaction: Transaction,
commitment: Commitment,
): Promise<RpcResponseAndContext<SimulatedTransactionResponse>> {
// @ts-ignore
transaction.recentBlockhash = await connection._recentBlockhash(
// @ts-ignore
connection._disableBlockhashCaching,
);
const signData = transaction.serializeMessage();
// @ts-ignore
const wireTransaction = transaction._serialize(signData);
const encodedTransaction = wireTransaction.toString('base64');
const config: any = { encoding: 'base64', commitment };
const args = [encodedTransaction, config];
// @ts-ignore
const res = await connection._rpcRequest('simulateTransaction', args);
if (res.error) {
throw new Error('failed to simulate transaction: ' + res.error.message);
}
return res.result;
}
async function awaitTransactionSignatureConfirmation(
txid: TransactionSignature,
timeout: number,
connection: Connection,
commitment: Commitment = 'recent',
queryStatus = false,
): Promise<SignatureStatus | null | void> {
let done = false;
let status: SignatureStatus | null | void = {
slot: 0,
confirmations: 0,
err: null,
};
let subId = 0;
status = await new Promise(async (resolve, reject) => {
setTimeout(() => {
if (done) {
return;
}
done = true;
console.log('Rejecting for timeout...');
reject({ timeout: true });
}, timeout);
try {
subId = connection.onSignature(
txid,
(result, context) => {
done = true;
status = {
err: result.err,
slot: context.slot,
confirmations: 0,
};
if (result.err) {
console.log('Rejected via websocket', result.err);
reject(status);
} else {
console.log('Resolved via websocket', result);
resolve(status);
}
},
commitment,
);
} catch (e) {
done = true;
console.error('WS error in setup', txid, e);
}
while (!done && queryStatus) {
// eslint-disable-next-line no-loop-func
(async () => {
try {
const signatureStatuses = await connection.getSignatureStatuses([
txid,
]);
status = signatureStatuses && signatureStatuses.value[0];
if (!done) {
if (!status) {
console.log('REST null result for', txid, status);
} else if (status.err) {
console.log('REST error for', txid, status);
done = true;
reject(status.err);
} else if (!status.confirmations) {
console.log('REST no confirmations for', txid, status);
} else {
console.log('REST confirmation for', txid, status);
done = true;
resolve(status);
}
}
} catch (e) {
if (!done) {
console.log('REST connection error: txid', txid, e);
}
}
})();
await sleep(2000);
}
});
//@ts-ignore
if (connection._signatureSubscriptions[subId])
connection.removeSignatureListener(subId);
done = true;
console.log('Returning status', status);
return status;
}