Skip to content

Commit fcd89f9

Browse files
committed
fix broadcast logic for apt and ton
1 parent 3b61720 commit fcd89f9

File tree

2 files changed

+244
-14
lines changed

2 files changed

+244
-14
lines changed

src/server/handlers/private-api.ts

Lines changed: 242 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
fetchChainzBalance,
1313
fetchBlockstreamBalance,
1414
ensureAuthKeyInPath,
15+
getBlockstreamAccessToken,
16+
BLOCKSTREAM_API_BASE,
1517
} from "../helper";
1618

1719
import { pipe } from "../util";
@@ -399,6 +401,32 @@ const normalizeBase64Payload = (value: unknown): string => {
399401
return trimmed;
400402
};
401403

404+
const normalizeAptosPayload = (value: unknown): string => {
405+
if (typeof value === "string") {
406+
const trimmed = value.trim();
407+
if (!trimmed) {
408+
throw new Error("Aptos payload must not be empty");
409+
}
410+
// Validate it's valid JSON
411+
try {
412+
JSON.parse(trimmed);
413+
} catch (err) {
414+
throw new Error("Aptos payload must be valid JSON");
415+
}
416+
return trimmed;
417+
}
418+
419+
if (value && typeof value === "object") {
420+
try {
421+
return JSON.stringify(value);
422+
} catch (err) {
423+
throw new Error("Failed to serialize Aptos payload to JSON");
424+
}
425+
}
426+
427+
throw new Error("Aptos payload must be a JSON string or object");
428+
};
429+
402430
const stripLeadingZeros = (value: string): string => {
403431
const stripped = value.replace(/^0+/, "");
404432
return stripped === "" ? "0" : stripped;
@@ -730,6 +758,154 @@ const broadcastTronTransaction = async (
730758
};
731759
};
732760

761+
const broadcastTonTransaction = async (
762+
node: ChainstackNode,
763+
signedPayload: string,
764+
): Promise<ChainBroadcastResponse> => {
765+
const endpointCandidates = [node.details?.toncenter_api_v3, node.details?.toncenter_api_v2]
766+
.filter((e): e is string => Boolean(e))
767+
.map((e) => ensureAuthKeyInPath(e, node.details?.auth_key));
768+
769+
if (endpointCandidates.length === 0) {
770+
throw new Error("TON node does not provide a Toncenter endpoint");
771+
}
772+
773+
const headers: Record<string, string> = {
774+
Accept: "application/json, text/plain;q=0.9, */*;q=0.8",
775+
"Content-Type": "application/json",
776+
"User-Agent": "EcencyBalanceBot/1.0 (+https://ecency.com)",
777+
};
778+
779+
const auth =
780+
node.details?.auth_username && node.details?.auth_password
781+
? {
782+
username: node.details.auth_username as string,
783+
password: node.details.auth_password as string,
784+
}
785+
: undefined;
786+
787+
const apiKey = node.details?.auth_key;
788+
789+
// Try JSON-RPC sendBoc method
790+
for (const baseEndpoint of endpointCandidates) {
791+
const postTargets = new Set<string>();
792+
const baseSanitized = baseEndpoint.replace(/\/+$/, "");
793+
const lower = baseSanitized.toLowerCase();
794+
795+
if (lower.endsWith("/jsonrpc")) {
796+
postTargets.add(baseSanitized);
797+
} else {
798+
postTargets.add(`${baseSanitized}/jsonrpc`);
799+
postTargets.add(baseSanitized);
800+
}
801+
802+
// Try both param formats for compatibility
803+
const paramFormats = [
804+
{ boc: signedPayload }, // v3 format
805+
[signedPayload], // v2 array format
806+
];
807+
808+
for (const url of postTargets) {
809+
for (const params of paramFormats) {
810+
try {
811+
const payload = {
812+
id: "broadcast",
813+
jsonrpc: "2.0",
814+
method: "sendBoc",
815+
params,
816+
};
817+
818+
const cfg: AxiosRequestConfig = {
819+
headers,
820+
auth,
821+
timeout: TON_TIMEOUT_MS,
822+
params: apiKey ? { api_key: apiKey } : undefined,
823+
validateStatus: (s) => s >= 200 && s < 500,
824+
};
825+
826+
const { data, status } = await axios.post(url, payload, cfg);
827+
828+
if (typeof data === "string" && /<[^>]+>/.test(data)) {
829+
continue;
830+
}
831+
832+
if (data?.error) {
833+
const msg = data.error?.message || data.error;
834+
// Continue to next format if error, don't throw yet
835+
continue;
836+
}
837+
838+
// Success - return result
839+
return {
840+
chain: "ton",
841+
txId: data?.result?.hash,
842+
raw: data,
843+
nodeId: node.id,
844+
provider: "chainstack",
845+
};
846+
} catch (e) {
847+
if (axios.isAxiosError(e) && (e.response?.status === 404 || e.response?.status === 405)) {
848+
continue;
849+
}
850+
// Continue to next format/endpoint
851+
continue;
852+
}
853+
}
854+
}
855+
}
856+
857+
throw new Error("TON broadcast failed on all endpoints");
858+
};
859+
860+
const broadcastAptosTransaction = async (
861+
node: ChainstackNode,
862+
signedPayload: string,
863+
): Promise<ChainBroadcastResponse> => {
864+
const endpoint = ensureHttpsEndpoint(node);
865+
const config = buildNodeAxiosConfig(node);
866+
const sanitizedEndpoint = endpoint.replace(/\/+$/, "");
867+
const url = `${sanitizedEndpoint}/v1/transactions`;
868+
869+
let parsedPayload: any;
870+
try {
871+
parsedPayload = JSON.parse(signedPayload);
872+
} catch (err) {
873+
throw new Error("Aptos payload must be valid JSON");
874+
}
875+
876+
const response = await axios.post(url, parsedPayload, {
877+
...config,
878+
headers: {
879+
...config.headers,
880+
"Content-Type": "application/json",
881+
},
882+
});
883+
884+
const data = response.data;
885+
886+
// Check for error responses
887+
if (data?.message && !data?.hash) {
888+
throw new Error(data.message || "Aptos broadcast failed");
889+
}
890+
891+
if (data?.error_code) {
892+
const errorMsg = data?.message || data?.error_code;
893+
throw new Error(`Aptos broadcast failed: ${errorMsg}`);
894+
}
895+
896+
if (!data?.hash) {
897+
throw new Error("Aptos broadcast failed: no transaction hash returned");
898+
}
899+
900+
return {
901+
chain: "apt",
902+
txId: data.hash,
903+
raw: data,
904+
nodeId: node.id,
905+
provider: "chainstack",
906+
};
907+
};
908+
733909
const requestToncenterBalance = async (
734910
baseEndpoint: string,
735911
address: string,
@@ -1434,6 +1610,36 @@ const fetchBitcoinBalance = async (node: ChainstackNode, address: string): Promi
14341610

14351611
const normalizeBitcoinPayload = (value: unknown): string => normalizeHexPayload(value).slice(2);
14361612

1613+
const broadcastBitcoinViaBlockstream = async (signedPayload: string): Promise<ChainBroadcastResponse> => {
1614+
const token = await getBlockstreamAccessToken();
1615+
1616+
const response = await axios.post(
1617+
`${BLOCKSTREAM_API_BASE}/tx`,
1618+
signedPayload,
1619+
{
1620+
headers: {
1621+
Authorization: `Bearer ${token}`,
1622+
"Content-Type": "text/plain",
1623+
},
1624+
timeout: 15000,
1625+
}
1626+
);
1627+
1628+
const txId = typeof response.data === "string" ? response.data.trim() : response.data?.txid;
1629+
1630+
if (!txId) {
1631+
throw new Error("Blockstream broadcast failed: no txid returned");
1632+
}
1633+
1634+
return {
1635+
chain: "btc",
1636+
txId,
1637+
raw: response.data,
1638+
nodeId: "blockstream-fallback",
1639+
provider: "chainstack",
1640+
};
1641+
};
1642+
14371643
const broadcastBitcoinTransaction = async (
14381644
node: ChainstackNode,
14391645
signedPayload: string,
@@ -1453,20 +1659,34 @@ const broadcastBitcoinTransaction = async (
14531659
params: [signedPayload],
14541660
};
14551661

1456-
const response = await axios.post(endpoint, payload, config);
1457-
const data = response.data;
1662+
try {
1663+
const response = await axios.post(endpoint, payload, config);
1664+
const data = response.data;
14581665

1459-
if (data?.error) {
1460-
throw new Error(data.error?.message || "Bitcoin broadcast failed");
1461-
}
1666+
if (data?.error) {
1667+
throw new Error(data.error?.message || "Bitcoin broadcast failed");
1668+
}
14621669

1463-
return {
1464-
chain: "btc",
1465-
txId: data?.result,
1466-
raw: data,
1467-
nodeId: node.id,
1468-
provider: "chainstack",
1469-
};
1670+
return {
1671+
chain: "btc",
1672+
txId: data?.result,
1673+
raw: data,
1674+
nodeId: node.id,
1675+
provider: "chainstack",
1676+
};
1677+
} catch (primaryError) {
1678+
console.warn("Chainstack BTC broadcast failed, trying Blockstream fallback", primaryError);
1679+
1680+
try {
1681+
return await broadcastBitcoinViaBlockstream(signedPayload);
1682+
} catch (blockstreamError) {
1683+
console.error("All BTC broadcast providers failed", {
1684+
chainstack: primaryError,
1685+
blockstream: blockstreamError,
1686+
});
1687+
throw primaryError;
1688+
}
1689+
}
14701690
};
14711691

14721692
const CHAIN_HANDLERS: Record<string, ChainHandler> = {
@@ -1537,6 +1757,16 @@ const CHAIN_BROADCAST_HANDLERS: Record<string, ChainBroadcastHandler> = {
15371757
selectNode: CHAIN_HANDLERS.sol.selectNode,
15381758
broadcast: (node, payload) => broadcastSolanaTransaction(node, payload as string),
15391759
},
1760+
ton: {
1761+
normalizePayload: normalizeBase64Payload,
1762+
selectNode: CHAIN_HANDLERS.ton.selectNode,
1763+
broadcast: (node, payload) => broadcastTonTransaction(node, payload as string),
1764+
},
1765+
apt: {
1766+
normalizePayload: normalizeAptosPayload,
1767+
selectNode: CHAIN_HANDLERS.apt.selectNode,
1768+
broadcast: (node, payload) => broadcastAptosTransaction(node, payload as string),
1769+
},
15401770
};
15411771

15421772
export const balance = async (req: express.Request, res: express.Response) => {

src/server/helper.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export const parseBalanceProvider = (value: unknown): BalanceProvider => {
155155
const BLOCKSTREAM_TOKEN_CACHE_KEY = "blockstream:access-token";
156156
const BLOCKSTREAM_TOKEN_URL =
157157
"https://login.blockstream.com/realms/blockstream-public/protocol/openid-connect/token";
158-
const BLOCKSTREAM_API_BASE = "https://enterprise.blockstream.info/api";
158+
export const BLOCKSTREAM_API_BASE = "https://enterprise.blockstream.info/api";
159159

160160
const toBigInt = (value: unknown): bigint => {
161161
if (typeof value === "bigint") return value;
@@ -188,7 +188,7 @@ const parseRateLimitHeader = (headerValue: unknown): number | undefined => {
188188
return Number.isFinite(num) ? num : undefined;
189189
};
190190

191-
const getBlockstreamAccessToken = async (): Promise<string> => {
191+
export const getBlockstreamAccessToken = async (): Promise<string> => {
192192
const cached = cache.get<string>(BLOCKSTREAM_TOKEN_CACHE_KEY);
193193
if (cached) return cached;
194194

0 commit comments

Comments
 (0)