diff --git a/apps/web/index.html b/apps/web/index.html
index e74a71b..191aff0 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -2,6 +2,7 @@
+
Escrow Program
diff --git a/apps/web/public/solana-logo.svg b/apps/web/public/solana-logo.svg
new file mode 100644
index 0000000..ed6f34d
--- /dev/null
+++ b/apps/web/public/solana-logo.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/web/src/components/TxResult.tsx b/apps/web/src/components/TxResult.tsx
index 0ce0284..f32eee1 100644
--- a/apps/web/src/components/TxResult.tsx
+++ b/apps/web/src/components/TxResult.tsx
@@ -2,22 +2,16 @@ import { Badge, Button } from '@solana/design-system';
import { useClusterConfig } from '@/hooks/use-cluster-config';
import { getClusterFromClusterId, getSolanaExplorerUrl } from '@/lib/explorer';
+import { formatTransactionErrorWithLogs } from '@/lib/transactionErrors';
interface TxResultProps {
error: unknown;
signature: string | null | undefined;
}
-function getErrorMessage(error: unknown): string | null {
- if (!error) return null;
- if (error instanceof Error) return error.message;
- if (typeof error === 'string') return error;
- return 'Transaction failed';
-}
-
export function TxResult({ signature, error }: TxResultProps) {
const { id } = useClusterConfig();
- const errorMessage = getErrorMessage(error);
+ const errorMessage = error ? formatTransactionErrorWithLogs(error) : null;
if (!signature && !errorMessage) return null;
@@ -27,7 +21,7 @@ export function TxResult({ signature, error }: TxResultProps) {
return (
Failed
- {errorMessage}
+ {errorMessage}
);
}
diff --git a/apps/web/src/components/instructions/AllowMint.tsx b/apps/web/src/components/instructions/AllowMint.tsx
index b048012..a164fe7 100644
--- a/apps/web/src/components/instructions/AllowMint.tsx
+++ b/apps/web/src/components/instructions/AllowMint.tsx
@@ -3,8 +3,9 @@
import { useState } from 'react';
import { useSavedValues } from '@/contexts/SavedValuesContext';
import { useEscrowMutations } from '@/hooks/use-escrow-mutations';
+import { useTokenFormDefaults } from '@/hooks/use-token-form-defaults';
import { TxResult } from '@/components/TxResult';
-import { firstValidationError, validateAddress } from '@/lib/validation';
+import { firstValidationError, validateAddress, validateOptionalAddress } from '@/lib/validation';
import { FormField, SendButton } from './shared';
interface AllowMintProps {
@@ -25,7 +26,7 @@ export function AllowMint({
const { allowMint } = useEscrowMutations();
const { defaultEscrow, defaultMint, rememberEscrow, rememberMint } = useSavedValues();
const [escrow, setEscrow] = useState(initialEscrow);
- const [mint, setMint] = useState(initialMint);
+ const { clusterMint, mint, setMint, setTokenProgram, tokenProgram } = useTokenFormDefaults(initialMint);
const [formError, setFormError] = useState(null);
const handleSubmit = async (e: React.FormEvent) => {
@@ -36,13 +37,14 @@ export function AllowMint({
const validationError = firstValidationError(
validateAddress(escrow, 'Escrow address'),
validateAddress(mint, 'Mint address'),
+ validateOptionalAddress(tokenProgram, 'Token program'),
);
if (validationError) {
setFormError(validationError);
return;
}
- const result = await allowMint.mutateAsync({ escrow, mint }).catch(() => null);
+ const result = await allowMint.mutateAsync({ escrow, mint, tokenProgram }).catch(() => null);
if (!result) return;
rememberEscrow(escrow);
@@ -72,13 +74,20 @@ export function AllowMint({
label="Mint Address"
value={mint}
onChange={setMint}
- autoFillValue={defaultMint}
+ autoFillValue={defaultMint || clusterMint}
onAutoFill={setMint}
placeholder="SPL token mint to allow"
required
/>
>
)}
+
diff --git a/apps/web/src/components/instructions/BlockMint.tsx b/apps/web/src/components/instructions/BlockMint.tsx
index ccf87b8..249ce4e 100644
--- a/apps/web/src/components/instructions/BlockMint.tsx
+++ b/apps/web/src/components/instructions/BlockMint.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useSavedValues } from '@/contexts/SavedValuesContext';
import { useWallet } from '@/contexts/WalletContext';
import { useEscrowMutations } from '@/hooks/use-escrow-mutations';
+import { useTokenFormDefaults } from '@/hooks/use-token-form-defaults';
import { TxResult } from '@/components/TxResult';
import { firstValidationError, validateAddress, validateOptionalAddress } from '@/lib/validation';
import { FormField, SendButton } from './shared';
@@ -29,7 +30,7 @@ export function BlockMint({
const { blockMint } = useEscrowMutations();
const { defaultEscrow, defaultMint, rememberEscrow, rememberMint } = useSavedValues();
const [escrow, setEscrow] = useState(initialEscrow);
- const [mint, setMint] = useState(initialMint);
+ const { clusterMint, mint, setMint, setTokenProgram, tokenProgram } = useTokenFormDefaults(initialMint);
const [rentRecipient, setRentRecipient] = useState(initialRentRecipient);
const [formError, setFormError] = useState(null);
@@ -42,13 +43,14 @@ export function BlockMint({
validateAddress(escrow, 'Escrow address'),
validateAddress(mint, 'Mint address'),
validateOptionalAddress(rentRecipient, 'Rent recipient'),
+ validateOptionalAddress(tokenProgram, 'Token program'),
);
if (validationError) {
setFormError(validationError);
return;
}
- const result = await blockMint.mutateAsync({ escrow, mint, rentRecipient }).catch(() => null);
+ const result = await blockMint.mutateAsync({ escrow, mint, rentRecipient, tokenProgram }).catch(() => null);
if (!result) return;
rememberEscrow(escrow);
@@ -78,7 +80,7 @@ export function BlockMint({
label="Mint Address"
value={mint}
onChange={setMint}
- autoFillValue={defaultMint}
+ autoFillValue={defaultMint || clusterMint}
onAutoFill={setMint}
placeholder="SPL token mint to block"
required
@@ -92,6 +94,13 @@ export function BlockMint({
placeholder={account?.address ?? 'Defaults to connected wallet'}
hint="Address that receives rent from the closed allowed-mint account"
/>
+
diff --git a/apps/web/src/components/instructions/Deposit.tsx b/apps/web/src/components/instructions/Deposit.tsx
index b93cd95..257df8e 100644
--- a/apps/web/src/components/instructions/Deposit.tsx
+++ b/apps/web/src/components/instructions/Deposit.tsx
@@ -3,8 +3,14 @@
import { useState } from 'react';
import { useSavedValues } from '@/contexts/SavedValuesContext';
import { useEscrowMutations } from '@/hooks/use-escrow-mutations';
+import { useTokenFormDefaults } from '@/hooks/use-token-form-defaults';
import { TxResult } from '@/components/TxResult';
-import { firstValidationError, validateAddress, validatePositiveInteger } from '@/lib/validation';
+import {
+ firstValidationError,
+ validateAddress,
+ validateOptionalAddress,
+ validatePositiveInteger,
+} from '@/lib/validation';
import { FormField, SendButton } from './shared';
interface DepositProps {
@@ -27,7 +33,7 @@ export function Deposit({
const { deposit } = useEscrowMutations();
const { defaultEscrow, defaultMint, rememberEscrow, rememberMint, rememberReceipt } = useSavedValues();
const [escrow, setEscrow] = useState(initialEscrow);
- const [mint, setMint] = useState(initialMint);
+ const { clusterMint, mint, setMint, setTokenProgram, tokenProgram } = useTokenFormDefaults(initialMint);
const [amount, setAmount] = useState(initialAmount);
const [generatedSeed, setGeneratedSeed] = useState('');
const [generatedReceipt, setGeneratedReceipt] = useState('');
@@ -42,6 +48,7 @@ export function Deposit({
validateAddress(escrow, 'Escrow address'),
validateAddress(mint, 'Mint address'),
validatePositiveInteger(amount, 'Amount'),
+ validateOptionalAddress(tokenProgram, 'Token program'),
);
if (validationError) {
setFormError(validationError);
@@ -53,6 +60,7 @@ export function Deposit({
amount: BigInt(amount),
escrow,
mint,
+ tokenProgram,
})
.catch(() => null);
if (!result) return;
@@ -87,7 +95,7 @@ export function Deposit({
label="Mint Address"
value={mint}
onChange={setMint}
- autoFillValue={defaultMint}
+ autoFillValue={defaultMint || clusterMint}
onAutoFill={setMint}
placeholder="SPL token mint address"
required
@@ -103,6 +111,13 @@ export function Deposit({
hint="Amount in smallest token units (no decimals)"
required
/>
+
{generatedSeed && (
(null);
@@ -47,6 +48,7 @@ export function Withdraw({
validateAddress(mint, 'Mint address'),
validateAddress(receipt, 'Receipt address'),
validateOptionalAddress(rentRecipient, 'Rent recipient'),
+ validateOptionalAddress(tokenProgram, 'Token program'),
);
if (validationError) {
setFormError(validationError);
@@ -59,6 +61,7 @@ export function Withdraw({
mint,
receipt,
rentRecipient,
+ tokenProgram,
})
.catch(() => null);
if (!result) return;
@@ -91,7 +94,7 @@ export function Withdraw({
label="Mint Address"
value={mint}
onChange={setMint}
- autoFillValue={defaultMint}
+ autoFillValue={defaultMint || clusterMint}
onAutoFill={setMint}
placeholder="SPL token mint address"
required
@@ -115,6 +118,13 @@ export function Withdraw({
placeholder={account?.address ?? 'Defaults to connected wallet'}
hint="Address that receives rent from the closed receipt account"
/>
+
diff --git a/apps/web/src/components/solana/use-wallet-transaction-sign-and-send.ts b/apps/web/src/components/solana/use-wallet-transaction-sign-and-send.ts
index ed5fe32..359e97b 100644
--- a/apps/web/src/components/solana/use-wallet-transaction-sign-and-send.ts
+++ b/apps/web/src/components/solana/use-wallet-transaction-sign-and-send.ts
@@ -37,7 +37,6 @@ export function useWalletTransactionSignAndSend() {
await sendAndConfirm(signedTx, {
commitment: 'confirmed',
- skipPreflight: true,
});
return signature;
diff --git a/apps/web/src/components/use-transaction-toast.tsx b/apps/web/src/components/use-transaction-toast.tsx
index d4a43fe..69954cc 100644
--- a/apps/web/src/components/use-transaction-toast.tsx
+++ b/apps/web/src/components/use-transaction-toast.tsx
@@ -5,7 +5,7 @@ import { getClusterFromClusterId, getSolanaExplorerUrl } from '@/lib/explorer';
import { formatTransactionError } from '@/lib/transactionErrors';
function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : formatTransactionError(error);
+ return formatTransactionError(error);
}
export function useTransactionToast() {
diff --git a/apps/web/src/contexts/RecentTransactionsContext.tsx b/apps/web/src/contexts/RecentTransactionsContext.tsx
index cb717a6..9f21df9 100644
--- a/apps/web/src/contexts/RecentTransactionsContext.tsx
+++ b/apps/web/src/contexts/RecentTransactionsContext.tsx
@@ -15,6 +15,7 @@ export interface RecentTransactionValues {
lockDuration?: string;
hookProgram?: string;
rentRecipient?: string;
+ tokenProgram?: string;
}
export interface RecentTransaction {
diff --git a/apps/web/src/hooks/use-escrow-mutations.ts b/apps/web/src/hooks/use-escrow-mutations.ts
index 9b623d3..2075876 100644
--- a/apps/web/src/hooks/use-escrow-mutations.ts
+++ b/apps/web/src/hooks/use-escrow-mutations.ts
@@ -35,6 +35,7 @@ import { useProgramContext } from '@/contexts/ProgramContext';
import type { RecentTransactionValues } from '@/contexts/RecentTransactionsContext';
import { useRecentTransactions } from '@/contexts/RecentTransactionsContext';
import { useWallet } from '@/contexts/WalletContext';
+import { normalizeTokenProgram } from '@/lib/token';
import { formatTransactionError } from '@/lib/transactionErrors';
import { invalidateWithDelay } from '@/lib/utils';
@@ -66,6 +67,7 @@ export interface DepositInput {
readonly amount: bigint;
readonly escrow: string;
readonly mint: string;
+ readonly tokenProgram: string;
}
export interface WithdrawInput {
@@ -73,11 +75,13 @@ export interface WithdrawInput {
readonly mint: string;
readonly receipt: string;
readonly rentRecipient?: string;
+ readonly tokenProgram: string;
}
export interface EscrowMintInput {
readonly escrow: string;
readonly mint: string;
+ readonly tokenProgram: string;
}
export interface BlockMintInput extends EscrowMintInput {
@@ -234,6 +238,7 @@ export function useEscrowMutations() {
const programAddress = getProgramAddress(programId);
const escrow = input.escrow.trim();
const mint = input.mint.trim();
+ const tokenProgram = normalizeTokenProgram(input.tokenProgram);
const receiptSeed = await generateKeyPairSigner();
const [receipt] = await findReceiptPda(
{
@@ -253,6 +258,7 @@ export function useEscrowMutations() {
mint: asAddress(mint),
payer: txSigner,
receiptSeed,
+ tokenProgram,
},
{ programAddress },
);
@@ -262,6 +268,7 @@ export function useEscrowMutations() {
escrow,
mint,
receipt,
+ tokenProgram: tokenProgram.toString(),
});
return { amount: input.amount, escrow, mint, receipt, receiptSeed: receiptSeed.address, signature };
},
@@ -277,6 +284,7 @@ export function useEscrowMutations() {
const mint = input.mint.trim();
const receipt = input.receipt.trim();
const rentRecipient = input.rentRecipient?.trim() || txSigner.address;
+ const tokenProgram = normalizeTokenProgram(input.tokenProgram);
const [extensionsPda] = await findExtensionsPda({ escrow: asAddress(escrow) }, { programAddress });
const extensionsAccount = await fetchEncodedAccount(rpc, extensionsPda);
const remainingAccounts: (AccountMeta | AccountSignerMeta)[] = [];
@@ -299,6 +307,7 @@ export function useEscrowMutations() {
mint: asAddress(mint),
receipt: asAddress(receipt),
rentRecipient: asAddress(rentRecipient),
+ tokenProgram,
withdrawer: txSigner,
},
{ programAddress },
@@ -313,6 +322,7 @@ export function useEscrowMutations() {
mint,
receipt,
rentRecipient,
+ tokenProgram: tokenProgram.toString(),
});
return { signature };
},
@@ -326,16 +336,22 @@ export function useEscrowMutations() {
const programAddress = getProgramAddress(programId);
const escrow = input.escrow.trim();
const mint = input.mint.trim();
+ const tokenProgram = normalizeTokenProgram(input.tokenProgram);
const instruction = await getAllowMintInstructionAsync(
{
admin: txSigner,
escrow: asAddress(escrow),
mint: asAddress(mint),
payer: txSigner,
+ tokenProgram,
},
{ programAddress },
);
- const signature = await sendEscrowTransaction([instruction], txSigner, 'Allow Mint', { escrow, mint });
+ const signature = await sendEscrowTransaction([instruction], txSigner, 'Allow Mint', {
+ escrow,
+ mint,
+ tokenProgram: tokenProgram.toString(),
+ });
return { signature };
},
onError,
@@ -349,12 +365,14 @@ export function useEscrowMutations() {
const escrow = input.escrow.trim();
const mint = input.mint.trim();
const rentRecipient = input.rentRecipient?.trim() || txSigner.address;
+ const tokenProgram = normalizeTokenProgram(input.tokenProgram);
const instruction = await getBlockMintInstructionAsync(
{
admin: txSigner,
escrow: asAddress(escrow),
mint: asAddress(mint),
rentRecipient: asAddress(rentRecipient),
+ tokenProgram,
},
{ programAddress },
);
@@ -362,6 +380,7 @@ export function useEscrowMutations() {
escrow,
mint,
rentRecipient,
+ tokenProgram: tokenProgram.toString(),
});
return { signature };
},
diff --git a/apps/web/src/hooks/use-token-form-defaults.ts b/apps/web/src/hooks/use-token-form-defaults.ts
new file mode 100644
index 0000000..4fd4763
--- /dev/null
+++ b/apps/web/src/hooks/use-token-form-defaults.ts
@@ -0,0 +1,34 @@
+import { useEffect, useRef, useState } from 'react';
+
+import { getClusterUsdcMint, TOKEN_PROGRAM_ID } from '@/lib/token';
+
+import { useClusterConfig } from './use-cluster-config';
+
+export function useTokenFormDefaults(initialMint = '') {
+ const { id } = useClusterConfig();
+ const clusterMint = getClusterUsdcMint(id);
+ const previousClusterMintRef = useRef(clusterMint);
+ const [mint, setMint] = useState(initialMint || clusterMint);
+ const [tokenProgram, setTokenProgram] = useState(TOKEN_PROGRAM_ID);
+
+ useEffect(() => {
+ const previousClusterMint = previousClusterMintRef.current;
+ previousClusterMintRef.current = clusterMint;
+
+ if (initialMint) return;
+
+ setMint(current => {
+ if (!current) return clusterMint;
+ if (current === previousClusterMint) return clusterMint;
+ return current;
+ });
+ }, [clusterMint, initialMint]);
+
+ return {
+ clusterMint,
+ mint,
+ setMint,
+ setTokenProgram,
+ tokenProgram,
+ };
+}
diff --git a/apps/web/src/hooks/useSendTx.ts b/apps/web/src/hooks/useSendTx.ts
index 04dd1c0..eb1cf7d 100644
--- a/apps/web/src/hooks/useSendTx.ts
+++ b/apps/web/src/hooks/useSendTx.ts
@@ -73,7 +73,6 @@ export function useSendTx() {
await sendAndConfirm(signedTx, {
commitment: 'confirmed',
- skipPreflight: true,
});
addRecentTransaction({
action: options?.action ?? 'Transaction',
diff --git a/apps/web/src/lib/token.ts b/apps/web/src/lib/token.ts
new file mode 100644
index 0000000..0f26ea1
--- /dev/null
+++ b/apps/web/src/lib/token.ts
@@ -0,0 +1,15 @@
+import { address, type Address } from '@solana/kit';
+
+export const DEVNET_USDC_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
+export const MAINNET_USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
+export const TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
+
+export function getClusterUsdcMint(clusterId: string) {
+ if (clusterId === 'solana:devnet') return DEVNET_USDC_MINT;
+ if (clusterId === 'solana:mainnet') return MAINNET_USDC_MINT;
+ return '';
+}
+
+export function normalizeTokenProgram(value: string): Address {
+ return address(value.trim() || TOKEN_PROGRAM_ID);
+}
diff --git a/apps/web/src/lib/transactionErrors.ts b/apps/web/src/lib/transactionErrors.ts
index 7d0bb6c..5728595 100644
--- a/apps/web/src/lib/transactionErrors.ts
+++ b/apps/web/src/lib/transactionErrors.ts
@@ -19,6 +19,12 @@ import {
ESCROW_PROGRAM_ERROR__TOKEN_EXTENSION_NOT_BLOCKED,
ESCROW_PROGRAM_ERROR__ZERO_DEPOSIT_AMOUNT,
} from '@solana/escrow';
+import {
+ isSolanaError,
+ SOLANA_ERROR__INSTRUCTION_ERROR__CUSTOM,
+ SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE,
+ unwrapSimulationError,
+} from '@solana/kit';
const ESCROW_PROGRAM_ERROR_MESSAGES: Record = {
[ESCROW_PROGRAM_ERROR__INVALID_ESCROW_ID]: 'Escrow ID invalid or does not respect rules',
@@ -41,13 +47,30 @@ const ESCROW_PROGRAM_ERROR_MESSAGES: Record = {
};
const FALLBACK_TX_FAILED_MESSAGE = 'Transaction failed';
+const MAX_LOG_LINES = 12;
+
+export interface TransactionErrorDetails {
+ readonly logs: readonly string[];
+ readonly message: string;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
function getErrorMessage(error: unknown): string {
- if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
+ if (error instanceof Error) return error.message;
+ if (isRecord(error) && typeof error.message === 'string') return error.message;
return '';
}
+function getErrorCause(error: unknown): unknown {
+ if (error instanceof Error) return error.cause;
+ if (isRecord(error)) return error.cause;
+ return undefined;
+}
+
function tryDecodePayload(payload: string): string | null {
if (typeof globalThis.atob !== 'function') {
return null;
@@ -93,17 +116,56 @@ function parseCustomProgramCodeFromString(message: string): number | null {
return null;
}
-function parseCustomProgramCode(error: unknown): number | null {
- if (error && typeof error === 'object') {
- const withContext = error as { context?: { code?: unknown } };
- if (typeof withContext.context?.code === 'number') {
- return withContext.context.code;
+function parseInstructionErrorCode(value: unknown): number | null {
+ if (!Array.isArray(value) || value.length < 2) return null;
+
+ const instructionError = value[1];
+ if (isRecord(instructionError) && typeof instructionError.Custom === 'number') return instructionError.Custom;
+ return null;
+}
+
+function parseCustomProgramCode(error: unknown, visited = new Set