Skip to content

Commit c8e2a90

Browse files
committed
feat: add bns support and default contracts
1 parent 05a970a commit c8e2a90

File tree

3 files changed

+254
-23
lines changed

3 files changed

+254
-23
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,8 @@ It includes forms/buttons for:
600600
3. `fund-pipe` (open pipe)
601601
4. `force-cancel`
602602
5. structured transfer message signing + payload JSON builder
603+
6. principal resolution for `.btc` names (for example `brice.btc`) via BNSv2 API
604+
7. preset Stackflow contract selection with token auto-fill for official STX/sBTC mainnet contracts
603605

604606
To publish with GitHub Pages (no build step):
605607

docs/app.js

Lines changed: 241 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,23 @@ const DEFAULT_API_BY_NETWORK = {
2121
};
2222

2323
const STACKFLOW_MESSAGE_VERSION = "0.6.0";
24+
const BNSV2_API_BASE = "https://api.bnsv2.com";
25+
const CONTRACT_PRESETS = {
26+
"stx-mainnet": {
27+
contractId: "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-0-6-0",
28+
tokenContract: "",
29+
},
30+
"sbtc-mainnet": {
31+
contractId: "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-sbtc-0-6-0",
32+
tokenContract: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token",
33+
},
34+
};
2435

2536
const elements = {
2637
network: document.getElementById("network"),
2738
stacksApiUrl: document.getElementById("stacks-api-url"),
2839
contractId: document.getElementById("contract-id"),
40+
contractPreset: document.getElementById("contract-preset"),
2941
counterparty: document.getElementById("counterparty"),
3042
tokenContract: document.getElementById("token-contract"),
3143
forPrincipal: document.getElementById("for-principal"),
@@ -54,6 +66,7 @@ const state = {
5466
connectedAddress: null,
5567
lastSignature: null,
5668
lastPayload: null,
69+
nameCache: new Map(),
5770
};
5871

5972
function normalizedText(value) {
@@ -64,6 +77,32 @@ function isStacksAddress(value) {
6477
return /^S[PMT][A-Z0-9]{38,42}$/i.test(normalizedText(value));
6578
}
6679

80+
function isPrincipalText(value) {
81+
const text = normalizedText(value);
82+
if (!text || !/^S/i.test(text)) {
83+
return false;
84+
}
85+
try {
86+
principalCV(text);
87+
return true;
88+
} catch {
89+
return false;
90+
}
91+
}
92+
93+
function getStacksApiBase() {
94+
const apiBase = normalizedText(elements.stacksApiUrl.value).replace(/\/+$/, "");
95+
if (!apiBase) {
96+
throw new Error("Stacks API URL is required");
97+
}
98+
return apiBase;
99+
}
100+
101+
function looksLikeBtcName(value) {
102+
const text = normalizedText(value).toLowerCase();
103+
return /^[a-z0-9][a-z0-9-]{0,36}\.btc$/.test(text);
104+
}
105+
67106
function nowStamp() {
68107
return new Date().toISOString().slice(11, 19);
69108
}
@@ -176,12 +215,131 @@ function readNetwork() {
176215
return network;
177216
}
178217

179-
function parseRequiredPrincipal(fieldName, value) {
180-
const principal = normalizedText(value);
181-
if (!isStacksAddress(principal)) {
182-
throw new Error(`${fieldName} must be a valid Stacks principal`);
218+
function extractPrincipalFromNamePayload(payload) {
219+
const visited = new Set();
220+
const crawl = (value) => {
221+
if (value == null) {
222+
return null;
223+
}
224+
if (typeof value === "string") {
225+
return isPrincipalText(value) ? value : null;
226+
}
227+
if (typeof value !== "object") {
228+
return null;
229+
}
230+
if (visited.has(value)) {
231+
return null;
232+
}
233+
visited.add(value);
234+
235+
if (Array.isArray(value)) {
236+
for (const entry of value) {
237+
const found = crawl(entry);
238+
if (found) {
239+
return found;
240+
}
241+
}
242+
return null;
243+
}
244+
245+
const priorityKeys = [
246+
"address",
247+
"owner",
248+
"owner_address",
249+
"ownerAddress",
250+
"current_owner",
251+
"principal",
252+
];
253+
for (const key of priorityKeys) {
254+
if (key in value) {
255+
const found = crawl(value[key]);
256+
if (found) {
257+
return found;
258+
}
259+
}
260+
}
261+
for (const nested of Object.values(value)) {
262+
const found = crawl(nested);
263+
if (found) {
264+
return found;
265+
}
266+
}
267+
return null;
268+
};
269+
270+
return crawl(payload);
271+
}
272+
273+
async function resolveBtcNameToPrincipal(name) {
274+
const normalizedName = normalizedText(name).toLowerCase();
275+
const network = readNetwork();
276+
const cacheKey = `${network}:${normalizedName}`;
277+
const cached = state.nameCache.get(cacheKey);
278+
if (cached) {
279+
return cached;
280+
}
281+
282+
const encoded = encodeURIComponent(normalizedName);
283+
const endpoints =
284+
network === "mainnet"
285+
? [`${BNSV2_API_BASE}/names/${encoded}`]
286+
: network === "testnet"
287+
? [`${BNSV2_API_BASE}/testnet/names/${encoded}`]
288+
: [`${BNSV2_API_BASE}/testnet/names/${encoded}`, `${BNSV2_API_BASE}/names/${encoded}`];
289+
290+
const failures = [];
291+
for (const endpoint of endpoints) {
292+
try {
293+
const response = await fetch(endpoint, {
294+
headers: { accept: "application/json" },
295+
});
296+
if (response.status === 404) {
297+
failures.push(`${response.status} ${endpoint}`);
298+
continue;
299+
}
300+
if (!response.ok) {
301+
failures.push(`${response.status} ${endpoint}`);
302+
continue;
303+
}
304+
const body = await response.json().catch(() => null);
305+
const principal = extractPrincipalFromNamePayload(body);
306+
if (principal) {
307+
state.nameCache.set(cacheKey, principal);
308+
return principal;
309+
}
310+
failures.push(`no-principal ${endpoint}`);
311+
} catch (error) {
312+
failures.push(
313+
`error ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
314+
);
315+
}
316+
}
317+
318+
throw new Error(
319+
`Could not resolve ${normalizedName}. Tried: ${failures.slice(0, 3).join(" | ")}`,
320+
);
321+
}
322+
323+
async function resolvePrincipalInput(fieldName, value, { required = true } = {}) {
324+
const input = normalizedText(value);
325+
if (!input) {
326+
if (required) {
327+
throw new Error(`${fieldName} is required`);
328+
}
329+
return null;
330+
}
331+
332+
if (isPrincipalText(input)) {
333+
return input;
183334
}
184-
return principal;
335+
336+
if (looksLikeBtcName(input)) {
337+
const principal = await resolveBtcNameToPrincipal(input);
338+
appendLog(`${fieldName}: resolved ${input} -> ${principal}`);
339+
return principal;
340+
}
341+
342+
throw new Error(`${fieldName} must be a Stacks principal or .btc name`);
185343
}
186344

187345
function parseOptionalTokenCV() {
@@ -324,6 +482,36 @@ function updateNetworkDefaults() {
324482
}
325483
}
326484

485+
function getPresetKeyByValues(contractId, tokenContract) {
486+
const contractText = normalizedText(contractId);
487+
const tokenText = normalizedText(tokenContract);
488+
for (const [presetKey, preset] of Object.entries(CONTRACT_PRESETS)) {
489+
if (
490+
normalizedText(preset.contractId) === contractText &&
491+
normalizedText(preset.tokenContract) === tokenText
492+
) {
493+
return presetKey;
494+
}
495+
}
496+
return "custom";
497+
}
498+
499+
function applyContractPreset(presetKey, { log = true } = {}) {
500+
const preset = CONTRACT_PRESETS[presetKey];
501+
if (!preset) {
502+
return;
503+
}
504+
elements.contractId.value = preset.contractId;
505+
elements.tokenContract.value = preset.tokenContract;
506+
if (log) {
507+
appendLog(
508+
`Applied preset ${presetKey}: contract=${preset.contractId}, token=${
509+
preset.tokenContract || "(none)"
510+
}`,
511+
);
512+
}
513+
}
514+
327515
async function handleConnectWallet() {
328516
try {
329517
const address = await ensureWallet({ interactive: true });
@@ -337,10 +525,7 @@ async function handleConnectWallet() {
337525

338526
async function fetchReadOnly(functionName, functionArgs, sender) {
339527
const { contractAddress, contractName } = parseContractId();
340-
const apiBase = normalizedText(elements.stacksApiUrl.value).replace(/\/+$/, "");
341-
if (!apiBase) {
342-
throw new Error("Stacks API URL is required");
343-
}
528+
const apiBase = getStacksApiBase();
344529
const url = `${apiBase}/v2/contracts/call-read/${contractAddress}/${contractName}/${functionName}`;
345530
const response = await fetch(url, {
346531
method: "POST",
@@ -365,8 +550,11 @@ async function fetchReadOnly(functionName, functionArgs, sender) {
365550

366551
async function handleGetPipe() {
367552
try {
368-
const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value);
369-
const forPrincipal = parseRequiredPrincipal(
553+
const withPrincipal = await resolvePrincipalInput(
554+
"Counterparty",
555+
elements.counterparty.value,
556+
);
557+
const forPrincipal = await resolvePrincipalInput(
370558
"For Principal",
371559
elements.forPrincipal.value || state.connectedAddress,
372560
);
@@ -416,7 +604,10 @@ async function handleOpenPipe() {
416604
if (!sender) {
417605
throw new Error("Connect wallet first");
418606
}
419-
const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value);
607+
const withPrincipal = await resolvePrincipalInput(
608+
"Counterparty",
609+
elements.counterparty.value,
610+
);
420611
const amount = parseUintInput("Amount", elements.openAmount.value, { min: 1n });
421612
const nonceText = normalizedText(elements.openNonce.value);
422613
const nonce = nonceText ? parseUintInput("Nonce", nonceText) : 0n;
@@ -458,7 +649,10 @@ async function handleOpenPipe() {
458649
async function handleForceCancel() {
459650
try {
460651
await ensureWallet({ interactive: true });
461-
const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value);
652+
const withPrincipal = await resolvePrincipalInput(
653+
"Counterparty",
654+
elements.counterparty.value,
655+
);
462656
const { cv: tokenCV } = parseOptionalTokenCV();
463657

464658
const response = await callContract("force-cancel", [
@@ -479,15 +673,18 @@ async function handleForceCancel() {
479673
}
480674
}
481675

482-
function buildTransferContext() {
676+
async function buildTransferContext() {
483677
const network = readNetwork();
484678
const { contractId } = parseContractId();
485-
const forPrincipal = parseRequiredPrincipal(
679+
const forPrincipal = await resolvePrincipalInput(
486680
"For Principal",
487681
elements.forPrincipal.value || state.connectedAddress,
488682
);
489-
const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value);
490-
const actor = parseRequiredPrincipal(
683+
const withPrincipal = await resolvePrincipalInput(
684+
"Counterparty",
685+
elements.counterparty.value,
686+
);
687+
const actor = await resolvePrincipalInput(
491688
"Actor Principal",
492689
elements.transferActor.value || forPrincipal,
493690
);
@@ -543,7 +740,7 @@ function buildTransferContext() {
543740
async function handleSignTransfer() {
544741
try {
545742
await ensureWallet({ interactive: true });
546-
const context = buildTransferContext();
743+
const context = await buildTransferContext();
547744
const response = await request("stx_signStructuredMessage", {
548745
domain: context.domain,
549746
message: context.message,
@@ -578,9 +775,9 @@ async function handleSignTransfer() {
578775
}
579776
}
580777

581-
function handleBuildPayload() {
778+
async function handleBuildPayload() {
582779
try {
583-
const context = buildTransferContext();
780+
const context = await buildTransferContext();
584781
const payload = {
585782
contractId: context.contractId,
586783
forPrincipal: context.forPrincipal,
@@ -626,11 +823,35 @@ function wireEvents() {
626823
elements.network.addEventListener("change", () => {
627824
elements.stacksApiUrl.value = DEFAULT_API_BY_NETWORK[readNetwork()];
628825
});
826+
elements.contractPreset.addEventListener("change", () => {
827+
applyContractPreset(elements.contractPreset.value);
828+
});
829+
elements.contractId.addEventListener("input", () => {
830+
elements.contractPreset.value = getPresetKeyByValues(
831+
elements.contractId.value,
832+
elements.tokenContract.value,
833+
);
834+
});
835+
elements.tokenContract.addEventListener("input", () => {
836+
elements.contractPreset.value = getPresetKeyByValues(
837+
elements.contractId.value,
838+
elements.tokenContract.value,
839+
);
840+
});
629841
}
630842

631843
async function bootstrap() {
632844
wireEvents();
633845
updateNetworkDefaults();
846+
if (!normalizedText(elements.contractId.value) && !normalizedText(elements.tokenContract.value)) {
847+
elements.contractPreset.value = "stx-mainnet";
848+
applyContractPreset("stx-mainnet", { log: false });
849+
} else {
850+
elements.contractPreset.value = getPresetKeyByValues(
851+
elements.contractId.value,
852+
elements.tokenContract.value,
853+
);
854+
}
634855
try {
635856
const address = await ensureWallet({ interactive: false });
636857
if (address) {

0 commit comments

Comments
 (0)