Link

For developers

Connect an app to Link

Link is the wallet on that person’s computer. Your program asks. Link pays and signs. You never open the wallet yourself.

Start

The person runs Link on the same computer as your program. Link listens on http://127.0.0.1:21736. Keep that address. It never leaves that computer.

The list of these pages is Nexus Developers. Connect and disconnect use two folders under %LOCALAPPDATA%\NexusNode. A payment, and a note you design, use that same door. Link asks, then pays or posts. Keep this shape. Pictures and tokens are a different page: Create and send ordinals.

What you need

A code-signing-ID

Dalais Org issues it to your @nexusnode.link paymail. Active means this wallet still holds it. Revoked, Expired, and Not held will not sign.

Nexus Signing Tool

Sign the program you ship. Leave YourApp.exe.nnlink.json in the same folder as YourApp.exe. The tool writes that file. You do not hand-write it.

Their Link

Link is open. They press Yes and pick the wallet. Your program does not pick the wallet, and it does not read the seed.

The program, not a script

Link checks the exe you signed. A script hosted by python.exe or node.exe is that host. Ship an exe, sign that exe, and run that exe when you ask.

A website uses a website-signing-ID. This page is the program. The website coin does not connect an exe, and it does not spend.

One Link

You do not get a private build of Link. The same Link on that person’s PC serves every certified app. A new field is a new name inside fields. It is not a change to Link.

Your pass

The coin plus the manifest beside the exe. Link reads the manifest. It does not keep a list of your program’s name.

Their yes

The first connect asks them which wallet. A later payment asks again, unless the coin carries auto, the Security lamp is on, and the amount is inside the Security Limit.

The messenger account

Fees and payments come from the messenger account. The ordinals account is never the fee.

Leave the wallet folder

The bind file names a wallet id. That id is not a key. Leave %LOCALAPPDATA%\NexusNode\wallets alone.

Connect

This works now. Write one small JSON file. Link reads it, checks the manifest, and asks the person.

Pick one instance name and keep it. Letters and numbers, such as PeytonApp. The same name every run. Link turns any other character into _, makes it lower case, and keeps the last 96 characters. That is the bind file name.

The request file is %LOCALAPPDATA%\NexusNode\connect\<id>.req.json. The id inside the file is the file name without .req.json. Use w plus the current Unix time in milliseconds.

{
  "id": "w1730000000000",
  "app": "PeytonApp",
  "instance": "PeytonApp",
  "exe": "C:\\Program\\PeytonApp.exe",
  "ts": 1730000000
}

exe is the full path of the program you signed. The manifest is that path plus .nnlink.json. ts is Unix seconds.

Then wait. Link writes %LOCALAPPDATA%\NexusNode\connect\<id>.rep.json.

{ "ok": true, "wallet_id": "w1730000000001" }

A yes also writes %LOCALAPPDATA%\NexusNode\binds\<instance>.json. A bind file with a wallet_id means this program is connected. Poll the reply about twice a second for 60 seconds. Ask the person to open Link if the reply never appears.

The first ask after a new coin can miss. Link refreshes a local cache and says the program is not signed. Open Link and ask again. The next ask reads the cache.

Disconnect

Delete the bind file for that same instance.

%LOCALAPPDATA%\NexusNode\binds\peytonapp.json

The instance PeytonApp becomes peytonapp. Deleting that file is the disconnect. The wallet stays. The signing coin stays. The next connect asks them to pick again.

If the bind file is already there, you are connected. Write a new request when you want them to pick again.

Payment

Link asks the person, then pays. One door serves every certified app.

POST http://127.0.0.1:21736/v1/app/pay with Content-Type: application/json. Link sees which program opened the connection. You do not put the exe path in this JSON.

{ "to": "someone@nexusnode.link", "sats": 1000 }

to is an @nexusnode.link name, any other paymail, or a 1… address. sats is a whole number, at least 1. That figure is what the payee receives. The miner fee sits on top. Link pays both from the messenger account.

{ "ok": true, "txid": "the-txid", "paymail": "payer@nexusnode.link" }

A no looks like {"ok":false,"error":"cancelled"}. Read ok before you read anything else.

The website button uses /v1/pay. A program uses /v1/app/pay.

Your own note

A note is an OP_RETURN you design. You name the protocol, the type, and the fields. Link signs the real fields and broadcasts. You do not add pubkey or sig. Link adds those.

POST http://127.0.0.1:21736/v1/app/post

{
  "protocol": "Peyton",
  "type": "note",
  "fields": {
    "title": "hello",
    "qty": "3"
  },
  "to": "someone@nexusnode.link",
  "sats": 546
}

protocol

Letters, numbers, _ and -. 1 to 32 characters. Yours. Link refuses a house name: TSC-Comms, NN-Art, NN-Emporium, NN-Identity, NN-Phone, NN-Link-App, NN-NexTrade.

type

Letters, numbers, and _. 1 to 32 characters. This is your record name, such as note or score.

fields

An object. Each name is letters, numbers, and _. Each value is text, so send "3" and not a bare number. Link sorts the names before it signs. Send them in any order.

Size

The UTF-8 of protocol, type, and fields together stays under 4096 bytes. A name or a value has no | character.

Leave to and sats out when the note has no payment. The person still pays the miner fee, because the note is a transaction. A payment is to and sats. It is not a field. Link shows the person the protocol, the type, every field, and the payment when there is one.

The same answer shape as a payment: ok, then txid and paymail.

Auto is the code-signing-ID carrying auto, the Security lamp on, and the amount inside the Security Limit. The limit defaults to 1 BSV. The lamp off always asks. Over the limit, Link asks. A note with no payment still asks when the lamp is off.

C#

A console program on .NET 6 or newer. App and Instance stay the same every run. Publish an exe, sign that exe, and run it.

using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

const string App = "PeytonApp";
const string Instance = "PeytonApp";
string Root = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "NexusNode");

string Sanitize(string key)
{
    var o = new StringBuilder();
    foreach (char c in key)
        o.Append(char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_');
    string s = o.ToString();
    if (s.Length > 96) s = s[^96..];
    return s.Length == 0 ? "default" : s;
}

string BindPath() => Path.Combine(Root, "binds", Sanitize(Instance) + ".json");

async Task<bool> Connect()
{
    if (File.Exists(BindPath()))
        return true;
    Directory.CreateDirectory(Path.Combine(Root, "connect"));
    string id = "w" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    var req = new
    {
        id,
        app = App,
        instance = Instance,
        exe = Environment.ProcessPath,
        ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
    };
    string file = Path.Combine(Root, "connect", id + ".req.json");
    await File.WriteAllTextAsync(file, JsonSerializer.Serialize(req));
    string reply = Path.Combine(Root, "connect", id + ".rep.json");
    for (int i = 0; i < 120 && !File.Exists(reply); i++)
        await Task.Delay(500);
    if (!File.Exists(reply))
        return false;
    using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(reply));
    return doc.RootElement.TryGetProperty("ok", out var ok) && ok.GetBoolean();
}

void Disconnect()
{
    if (File.Exists(BindPath()))
        File.Delete(BindPath());
}

// Link asks before it pays, so leave time for that Yes.
const string Link = "http://127.0.0.1:21736";
using var http = new HttpClient();
http.Timeout = TimeSpan.FromMinutes(3);

async Task<string> Post(string path, string json)
{
    var body = new StringContent(json, Encoding.UTF8, "application/json");
    return await (await http.PostAsync(Link + path, body)).Content.ReadAsStringAsync();
}

Console.WriteLine(await Connect() ? "connected" : "not connected");

// Payment. 1000 sats to that paymail. Link asks.
Console.WriteLine(await Post("/v1/app/pay",
    """{"to":"someone@nexusnode.link","sats":1000}"""));

// Your note. Delete a field you do not need. Values are text.
Console.WriteLine(await Post("/v1/app/post",
    """
    {"protocol":"Peyton","type":"note","fields":{"title":"hello","qty":"3"}}
    """));

// Disconnect();

Python

Python 3, no extra package. Set EXE to the program Nexus Signing Tool signed. Run that program when you ask. A script’s python.exe is not the program.

import json
import os
import time
import urllib.request

APP = "PeytonApp"
INSTANCE = "PeytonApp"
# Full path of the exe you signed. The manifest sits beside it.
EXE = r"C:\Program\PeytonApp.exe"
ROOT = os.path.join(os.environ["LOCALAPPDATA"], "NexusNode")
LINK = "http://127.0.0.1:21736"

def sanitize(key):
    out = "".join(c.lower() if c.isalnum() else "_" for c in key)
    if len(out) > 96:
        out = out[-96:]
    return out or "default"

def bind_path():
    return os.path.join(ROOT, "binds", sanitize(INSTANCE) + ".json")

def connect():
    if os.path.exists(bind_path()):
        return True
    os.makedirs(os.path.join(ROOT, "connect"), exist_ok=True)
    rid = "w" + str(int(time.time() * 1000))
    req = {
        "id": rid,
        "app": APP,
        "instance": INSTANCE,
        "exe": EXE,
        "ts": int(time.time()),
    }
    with open(os.path.join(ROOT, "connect", rid + ".req.json"), "w", encoding="utf-8") as f:
        json.dump(req, f)
    reply = os.path.join(ROOT, "connect", rid + ".rep.json")
    for _ in range(120):
        if os.path.exists(reply):
            break
        time.sleep(0.5)
    if not os.path.exists(reply):
        return False
    with open(reply, encoding="utf-8") as f:
        return json.load(f).get("ok") is True

def disconnect():
    if os.path.exists(bind_path()):
        os.remove(bind_path())

def post(path, body):
    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(LINK + path, data=data)
    req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req, timeout=120) as res:
        return json.load(res)

print("connected" if connect() else "not connected")

# Payment. Link asks.
print(post("/v1/app/pay", {"to": "someone@nexusnode.link", "sats": 1000}))

# Your note. Values are text. Delete a field you do not need.
print(post("/v1/app/post", {
    "protocol": "Peyton",
    "type": "note",
    "fields": {"title": "hello", "qty": "3"},
}))

# disconnect()

JavaScript

Node 18 or newer. This is a program, not a script you paste into a website. Set EXE to the program you signed. process.execPath is node.exe, and that is not your program.

import fs from "node:fs";
import path from "node:path";

const APP = "PeytonApp";
const INSTANCE = "PeytonApp";
// Full path of the exe you signed. The manifest sits beside it.
const EXE = "C:\\Program\\PeytonApp.exe";
const ROOT = path.join(process.env.LOCALAPPDATA, "NexusNode");
const LINK = "http://127.0.0.1:21736";

function sanitize(key) {
  let out = "";
  for (const c of key)
    out += /[A-Za-z0-9]/.test(c) ? c.toLowerCase() : "_";
  if (out.length > 96) out = out.slice(-96);
  return out || "default";
}

function bindPath() {
  return path.join(ROOT, "binds", sanitize(INSTANCE) + ".json");
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function connect() {
  if (fs.existsSync(bindPath())) return true;
  fs.mkdirSync(path.join(ROOT, "connect"), { recursive: true });
  const id = "w" + Date.now();
  const req = { id, app: APP, instance: INSTANCE, exe: EXE, ts: Math.floor(Date.now() / 1000) };
  fs.writeFileSync(path.join(ROOT, "connect", id + ".req.json"), JSON.stringify(req));
  const reply = path.join(ROOT, "connect", id + ".rep.json");
  for (let i = 0; i < 120 && !fs.existsSync(reply); i++)
    await sleep(500);
  if (!fs.existsSync(reply)) return false;
  return JSON.parse(fs.readFileSync(reply, "utf8")).ok === true;
}

function disconnect() {
  if (fs.existsSync(bindPath())) fs.unlinkSync(bindPath());
}

async function post(urlPath, body) {
  const res = await fetch(LINK + urlPath, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body)
  });
  return res.json();
}

console.log(await connect() ? "connected" : "not connected");

// Payment. Link asks.
console.log(await post("/v1/app/pay", { to: "someone@nexusnode.link", sats: 1000 }));

// Your note. Values are text. Delete a field you do not need.
console.log(await post("/v1/app/post", {
  protocol: "Peyton",
  type: "note",
  fields: { title: "hello", qty: "3" }
}));

// disconnect();

When it fails

No reply

Link is not open. Ask them to open Link, then write the request again.

ok false

They pressed No, or the manifest does not match the exe. Sign again after every rebuild. The manifest has to sit beside that exe.

Not signed, once

A new coin can miss the local cache. Ask again. The second ask reads the cache.

bad request

The amount, the protocol, or a field was refused. Values are text. A house protocol name is refused.

A failed pay or note looks like {"ok":false,"error":"not allowed"}. cancelled means they pressed No. not enough funds means the messenger account cannot cover the amount and the fee.