Skip to content
Draft
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
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
"url": "git+https://github.com/friedger/stacks-send-many.git"
},
"dependencies": {
"@scure/bip32": "^1.7.0",
"@scure/btc-signer": "^1.8.1",
"@stacks/blockchain-api-client": "^8.13.5",
"@stacks/common": "7.3.1",
"@stacks/connect": "^8.2.3",
Expand All @@ -28,6 +26,7 @@
"@walletconnect/types": "^2.23.1",
"@walletconnect/utils": "^2.23.1",
"c32check": "^2.0.0",
"clarity-abitype": "0.3.1",
"global": "^4.4.0",
"jdenticon": "^3.3.0",
"jotai": "^2.16.0",
Expand Down
32 changes: 26 additions & 6 deletions pnpm-lock.yaml

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

17 changes: 9 additions & 8 deletions src/components/Address.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ClarityType } from '@stacks/transactions';
import toUnicode from 'punycode2/to-unicode';
import { useEffect, useMemo, useState } from 'react';
import { hex_to_ascii } from '../lib/string-utils';
Expand All @@ -15,17 +14,19 @@ export function Address({ addr }: { addr: string }) {
const [showAscii, setShowAscii] = useState(false);

useEffect(() => {
getNameFromAddress(addr).then(data => {
if (data.type === ClarityType.ResponseOk && data.value.type === ClarityType.OptionalSome) {
const { name, namespace } = data.value.value.value;

const nameStr = hex_to_ascii(name.value);
const fn = async (addr: string) => {
const data = await getNameFromAddress(addr);
if (data.ok) {
const { name, namespace } = data.ok;
const nameStr = hex_to_ascii(name);
const namePunycodeStr = toUnicode(nameStr);
const namespaceStr = hex_to_ascii(namespace.value);
const namespaceStr = hex_to_ascii(namespace);
setNameAscii(nameStr === namePunycodeStr ? undefined : `${nameStr}.${namespaceStr}`);
setNameOrAddress(`${namePunycodeStr}.${namespaceStr}`);
}
});
};

fn(addr);
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for the async function call. If getNameFromAddress throws an error or rejects, the promise rejection will be unhandled. Consider adding a .catch() handler to the fn(addr) call to gracefully handle errors (e.g., network failures, invalid addresses). The error handler should silently fail or log the error without crashing the component, since the component already falls back to displaying the short address when no name is found.

Copilot uses AI. Check for mistakes.
}, [addr]);

return (
Expand Down
14 changes: 7 additions & 7 deletions src/components/SBTCInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cvToString, fetchCallReadOnlyFunction, PrincipalCV, TupleCV } from '@stacks/transactions';
import { useEffect, useState } from 'react';
import { typedCallReadOnlyFunction } from 'clarity-abitype';
import { NETWORK } from '../lib/constants';
import { sbtcRegistryAbi } from '../lib/abi';

export function SBTCInfo({ assetId }: { assetId: string }) {
const [info, setInfo] = useState<string>();
Expand All @@ -9,17 +10,16 @@ export function SBTCInfo({ assetId }: { assetId: string }) {
const fn = async () => {
const [contractId, _] = assetId.split('::');
const [contractAddress] = contractId.split('.');
const response = (await fetchCallReadOnlyFunction({
const response = await typedCallReadOnlyFunction({
abi: sbtcRegistryAbi,
contractAddress,
contractName: 'sbtc-registry',
functionName: 'get-current-signer-data',
functionArgs: [],
senderAddress: contractAddress,
network: NETWORK,
})) as TupleCV<{ 'current-signer-principal': PrincipalCV }>;
setInfo(
`Current sBTC signer Stacks address: ${cvToString(response.value['current-signer-principal'])}`
);
});

setInfo(`Current sBTC signer Stacks address: ${response['current-signer-principal']}`);
};
fn().catch(e => {
setInfo(`Failed to load signer data. (${e.message})`);
Expand Down
78 changes: 40 additions & 38 deletions src/components/SendManyInputContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import {
AuthType,
bufferCVFromString,
ClarityType,
contractPrincipalCV,
cvToString,
listCV,
noneCV,
Pc,
PostConditionMode,
principalCV,
PrincipalCV,
someCV,
standardPrincipalCV,
trueCV,
Expand Down Expand Up @@ -41,8 +38,9 @@ export type Row = {
stx: string;
memo?: string;
error?: string;
toCV?: PrincipalCV;
toCV?: string;
};

const addrToCV = (addr: string) => {
const toParts = addr.split('.');
if (toParts.length === 1) {
Expand All @@ -52,28 +50,17 @@ const addrToCV = (addr: string) => {
}
};

const addToCVValues = async <T extends Row>(parts: T[]) => {
return Promise.all(
parts.map(async p => {
if (p.to === '') {
return p;
}
try {
return { ...p, toCV: addrToCV(p.to) };
} catch (e) {
try {
const owner = await getNameInfo(toAscii(p.to));
if (owner.type === ClarityType.OptionalSome) {
return { ...p, toCV: owner.value.value.owner };
} else {
return { ...p, error: `No address for ${p.to}` };
}
} catch (e2) {
return { ...p, error: `${p.to} not found` };
}
}
})
);
const resolveRecipient = async (recipient: string): Promise<string> => {
try {
c32addressDecode(recipient);
return recipient;
} catch (e) {
const owner = await getNameInfo(toAscii(recipient));
if (owner?.owner) {
return owner.owner;
}
throw new Error(`No address for ${recipient}`);
}
Comment on lines +58 to +63
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling may be incorrect. According to the bnsV2Abi, the get-bns-info function returns an optional<tuple> (not a response type). With clarity-abitype, this means the result will be either the tuple object or null/undefined when the name doesn't exist. The current code checks owner?.owner, which assumes the result is the tuple. However, when a name doesn't exist, owner will be null/undefined, so the check should work. But for clarity, consider explicitly checking if (owner && owner.owner) or adding a comment explaining that owner is the optional tuple value.

Copilot uses AI. Check for mistakes.
};

function nonEmptyPart(p: Row) {
Expand Down Expand Up @@ -168,7 +155,7 @@ export function SendManyInputContainer({
setNamesResolved(!!p.toCV);
return (
<Fragment key={index}>
{p.error || (p.toCV ? <Address addr={cvToString(p.toCV)} /> : '...')}:{' '}
{p.error || (p.toCV ? <Address addr={p.toCV} /> : '...')}:{' '}
<Amount amount={p.ustx} asset={asset} /> <br />
<br />
</Fragment>
Expand Down Expand Up @@ -371,21 +358,36 @@ export function SendManyInputContainer({
let { parts, total, hasMemos } = getPartsFromRows(
useAssetForFees ? cloneAndAddFees(rows) : rows
);
const updatedParts = await addToCVValues(parts);
let invalidNames = updatedParts.filter(r => !!r.error);
if (invalidNames.length > 0) {
updatePreview({ parts: updatedParts, total, hasMemos });

const resolvedPartsWithErrors = await Promise.all(
parts.map(async p => {
if (p.to === '') {
return p;
}
try {
return { ...p, toCV: await resolveRecipient(p.to) };
} catch (e) {
return { ...p, error: `${p.to} not found` };
}
})
);

const errors = resolvedPartsWithErrors.filter(r => !!r.error);
if (errors.length > 0) {
updatePreview({ parts: resolvedPartsWithErrors, total, hasMemos });
setLoading(false);
setStatus('Please verify receivers');
return;
}

if (!namesResolved) {
updatePreview({ parts: updatedParts, total, hasMemos });
updatePreview({ parts: resolvedPartsWithErrors, total, hasMemos });
setLoading(false);
setNamesResolved(true);
return;
}
const nonEmptyParts = updatedParts.filter(nonEmptyPart);

const nonEmptyParts = resolvedPartsWithErrors.filter(nonEmptyPart);
console.log(nonEmptyParts[0]);
const firstMemo =
nonEmptyParts.length > 0 && nonEmptyParts[0].memo ? nonEmptyParts[0].memo.trim() : '';
Expand All @@ -406,13 +408,13 @@ export function SendManyInputContainer({
nonEmptyParts.map(p => {
return hasMemos
? tupleCV({
to: p.toCV!,
to: addrToCV(p.toCV!),
ustx: uintCV(p.ustx),
memo: bufferCVFromString(
firstMemoForAll ? firstMemo : p.memo ? p.memo.trim() : ''
),
})
: tupleCV({ to: p.toCV!, ustx: uintCV(p.ustx) });
: tupleCV({ to: addrToCV(p.toCV!), ustx: uintCV(p.ustx) });
})
),
],
Expand All @@ -431,7 +433,7 @@ export function SendManyInputContainer({
functionArgs: [
nonEmptyParts.map(p => {
return tupleCV({
to: p.toCV!,
to: addrToCV(p.toCV!),
'xbtc-in-sats': uintCV(p.ustx),
memo: bufferCVFromString(
hasMemos ? (firstMemoForAll ? firstMemo : p.memo ? p.memo.trim() : '') : ''
Expand Down Expand Up @@ -464,7 +466,7 @@ export function SendManyInputContainer({
listCV(
nonEmptyParts.map(p => {
return tupleCV({
to: p.toCV!,
to: addrToCV(p.toCV!),
sender: principalCV(ownerStxAddress),
amount: uintCV(p.ustx),
memo: hasMemos
Expand Down Expand Up @@ -503,7 +505,7 @@ export function SendManyInputContainer({
listCV(
nonEmptyParts.map(p => {
return tupleCV({
to: p.toCV!,
to: addrToCV(p.toCV!),
amount: uintCV(p.ustx),
memo: hasMemos
? firstMemoForAll
Expand Down
Loading