Link

Web3 security

Webgate

Spam bot, brute force & phishing protection.
A block of HTML you paste onto a page. A visitor pays a small amount from Link, the wallet on their own computer. Until that payment is made, the page shows a Continue button and nothing else.

Windows · needs Link · a published @nexusnode.link paymail

What it is

After the payment, the cover lifts and the page is visible. The payment is one transaction. It pays you, pays a house fee, and writes a signed record of who visited and which page they opened.

What the visitor needs

Link

Link, running on that computer. Phones, and any other computer, will see “No Link detected”.

A paymail

A published @nexusnode.link paymail in that Link.

Link listens only on that computer.

What you set

data-link-to

Your @nexusnode.link paymail, or a 1… payment address.

data-link-sats

What the visitor pays you. Minimum 200 sats. Maximum 1000.

data-link-message

A short note stored in the on-chain record. Do not use a | character.

data-link-deny

Where to send a visitor who does not have enough funds, or who cancels.

data-link-auto-pay

Off or on. Off is the default.

Link adds a house fee of 500 sats, paid to webgate@nexusnode.link. The HTML cannot remove that fee or send it to another address. Changing the fee is a change in Link, not in this block.

If Link is not running, the page says No Link detected and stays there, so the visitor can start Link and press Continue again. If the wallet cannot cover the payment, the page says Not enough funds, waits a moment, and then opens the deny address. A cancelled payment does the same.

The record

The transaction outputs are the amounts: yours, and the 500 sat house fee. The block time is the time. The note in the transaction is short. It contains the type web_gate, the visitor’s handle, the page origin, the page path, your message, a nonce, and a signature. The path does not include a query string.

The nonce in the sample block is a placeholder. That is enough when you only want the page covered. A site that must refuse a repeated payment replaces link_nonce with a new value each time the page is served, and accepts a form only after it has seen that value in the transaction.

A normal page

No plugin is required.

  1. 1Copy the HTML block at the bottom of this page.
  2. 2Set data-link-to, data-link-sats, data-link-message, and data-link-deny.
  3. 3Paste it into a Custom HTML block on the page.
  4. 4Publish the page.

Leave data-link-auto-pay as off when the visitor should press Continue, and then confirm the payment in Link. Set it to on when the page should pay as it loads, with no Continue click and no question in Link, for as long as Link’s own limit allows. That limit is 30 seconds between these payments, 3 a day for one site, and 8 a day across every site. After the limit, Continue returns and Link asks. The page cannot raise the limit.

The WordPress login screen

The address wp-login.php is not a page you can edit. A Custom HTML block cannot be placed on it. The Code Snippets plugin prints this block on that screen. You do not need to rename the login address.

  1. 1In wp-admin, open Plugins, choose Add New, search for Code Snippets, and activate it.
  2. 2Keep a logged-in tab open. Test the login screen in a private window.
  3. 3Open Snippets, then Add New.
  4. 4Title the snippet Webgate login. Leave the type as PHP.
  5. 5At the bottom of the editor, set the scope to Only run on site front-end.
  6. 6Paste the PHP below. Between those two lines, paste the HTML block.
  7. 7Set data-link-to and the other fields inside that HTML. Do not add data-link-post. After payment, the cover lifts and the WordPress username and password fields are already on the page.
  8. 8Click Save Changes and Activate.
  9. 9In the private window, open wp-login.php. Continue should sit over the login form.

If that window is blank, return to the tab that is still logged in and deactivate the snippet.

PHP for Code Snippets

Open the snippet with this. Do not add a <?php line of your own above it. Code Snippets already runs as PHP.

				
					add_action('login_footer', function () {
?>
				
			

Paste the HTML block here, then close the snippet with:

				
					<?php
});
				
			

The HTML block

Copy this into a Custom HTML block on a normal page. On the login screen, paste it between the two PHP lines above. It is shown as code, so it does not cover this page.

				
					<!--
  NexusNode Link web gate. Paste into a Custom HTML block on the page
  you want covered. Visitors see only Continue until they pay.

  Edit:
    data-link-to        your @nexusnode.link paymail, or a 1… payment address
    data-link-sats      sats to you. Minimum 200, maximum 1000.
    data-link-message   short note written on-chain (no | character). 160 bytes.
    data-link-deny      where to send them after Not enough funds, or a cancelled payment.
                        No Link detected stays on this page so they can start Link and press Continue.
    data-link-auto-pay  off (default) or on.
                        off: the visitor presses Continue, then Link asks.
                        on:  Link pays as the page loads. No Continue click and
                             no Link question, until Link's own limit:
                             30 seconds between silent pays,
                             3 silent pays for this site per day,
                             8 silent pays across every site per day.
                             A redirect loop or a chain of sites stops there.
                             Continue comes back, and Link asks. The page
                             cannot raise the limit.
    data-link-post      set to 1 only when this form must submit after payment (a login).
    link_nonce          a nonce YOUR SERVER minted for this attempt. 8–64 of [A-Za-z0-9_-].

  House fee is 500 sats to webgate@nexusnode.link, added by Link.
  The page cannot remove it. When data-link-auto-pay is on, Link pays
  without asking, inside the limit above. Past that limit it asks again.

  Server check, when you do post the form (0-conf counts):
    1. The nonce is one you issued, unused, for this session.
    2. Fetch https://api.whatsonchain.com/v1/bsv/main/tx/TXID
    3. An output pays your payment address exactly data-link-sats.
    4. An output pays 500 sats to the webgate@nexusnode.link.
    5. OP_RETURN JSON: type web_gate, handle, origin, path, message, nonce, pubkey, sig.
       Path has no query string. Amounts and time are the tx outputs and the block time.
    6. origin is https://your-host. nonce matches.
    7. Mark the txid and the nonce used.

  Canon (handle lowercased, @ stripped):
    NN1|web_gate|handle|origin|path|message|nonce
-->
<form method="post" action="/login"
      data-link-to="YOUR_NAME@nexusnode.link"
      data-link-sats="200"
      data-link-message="Login page"
      data-link-deny="/404"
      data-link-auto-pay="off">
  <input type="hidden" name="link_nonce" value="SERVER_MINTED_NONCE">
  <input type="hidden" name="link_txid" value="">
  <button type="submit">Continue</button>
</form>
<script>
(function () {
  var PORT = 21736;
  var BASE = "http://127.0.0.1:" + PORT;

  function deny(form) {
    var url = form.getAttribute("data-link-deny") || "";
    if (url)
      window.location.assign(url);
  }
  function nonceOf(form) {
    var el = form.querySelector('input[name="link_nonce"]');
    return el ? String(el.value || "") : "";
  }
  function setTxid(form, txid) {
    var el = form.querySelector('input[name="link_txid"]');
    if (!el) {
      el = document.createElement("input");
      el.type = "hidden";
      el.name = "link_txid";
      form.appendChild(el);
    }
    el.value = txid;
  }
  function cover(form) {
    if (form._nnCurtain)
      return;
    var curtain = document.createElement("div");
    curtain.setAttribute("style",
      "position:fixed;inset:0;z-index:2147483647;background:#12110f;display:flex;align-items:center;justify-content:center;");
    var box = document.createElement("div");
    box.setAttribute("style", "text-align:center;");
    var btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = "Continue";
    btn.setAttribute("style",
      "font:600 18px Segoe UI,sans-serif;padding:14px 36px;border:0;border-radius:8px;background:#e89420;color:#1a1208;cursor:pointer;");
    var note = document.createElement("p");
    note.setAttribute("style",
      "margin:18px 0 0;min-height:1.4em;color:#f3efe4;font:16px Segoe UI,sans-serif;");
    box.appendChild(btn);
    box.appendChild(note);
    curtain.appendChild(box);
    document.body.appendChild(curtain);
    form._nnCurtain = curtain;
    form._nnNote = note;
    form._nnBtn = btn;
    btn.addEventListener("click", function () {
      form._nnSilent = false;
      if (typeof form.requestSubmit === "function")
        form.requestSubmit();
      else
        form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
    });
  }
  function stay(form, msg) {
    form._nnBusy = false;
    form._nnSilent = false;
    if (form._nnBtn) {
      form._nnBtn.style.display = "";
      form._nnBtn.disabled = false;
    }
    if (form._nnNote)
      form._nnNote.textContent = msg;
  }
  function hold(form, msg) {
    if (form._nnNote)
      form._nnNote.textContent = msg;
    if (form._nnLeaving)
      return;
    form._nnLeaving = true;
    setTimeout(function () { deny(form); }, 2200);
  }
  function reason(err) {
    var msg = String((err && err.message) || "");
    var low = msg.toLowerCase();
    if (low === "no funds" || low.indexOf("0 in your") >= 0 || low.indexOf("insufficient") >= 0 || low.indexOf("not enough") >= 0)
      return "Not enough funds";
    if (low === "cancelled")
      return "Payment cancelled";
    if (!msg || low === "not-ready" || low === "failed to fetch" || (err && (err.name === "TypeError" || err.name === "AbortError")))
      return "No Link detected";
    return msg;
  }
  function call(path, opts) {
    var ctrl = (typeof AbortSignal !== "undefined" && AbortSignal.timeout)
      ? { signal: AbortSignal.timeout(180000) }
      : {};
    return fetch(BASE + path, Object.assign({ cache: "no-store", mode: "cors" }, ctrl, opts || {}))
      .then(function (r) { return r.json(); });
  }
  function autoOn(form) {
    return String(form.getAttribute("data-link-auto-pay") || "off").toLowerCase() === "on";
  }
  function reveal(form) {
    if (form._nnCurtain && form._nnCurtain.parentNode)
      form._nnCurtain.parentNode.removeChild(form._nnCurtain);
    form._nnCurtain = null;
    form.style.display = "none";
  }

  document.querySelectorAll("form[data-link-to]").forEach(function (form) {
    cover(form);
    if (autoOn(form) && form._nnBtn)
      form._nnBtn.style.display = "none";
    form.addEventListener("submit", function (ev) {
      if (form.getAttribute("data-link-paid") === "1")
        return;
      ev.preventDefault();
      if (form._nnBusy)
        return;
      var nonce = nonceOf(form);
      if (!nonce) {
        hold(form, "This page is not ready");
        return;
      }
      form._nnBusy = true;
      var silent = !!form._nnSilent;
      form._nnSilent = false;
      if (form._nnBtn)
        form._nnBtn.disabled = true;
      if (form._nnNote)
        form._nnNote.textContent = autoOn(form) ? "Asking Link…" : "";
      var path = window.location.pathname;
      if (!path || path.charAt(0) !== "/")
        path = "/";
      var sats = Number(form.getAttribute("data-link-sats") || "0");
      call("/v1/status?sats=" + encodeURIComponent(String(sats))).then(function (st) {
        if (!st || !st.ok || !st.ready)
          throw new Error("not-ready");
        if (st.funds === false)
          throw new Error("no funds");
        return call("/v1/pay", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            to: form.getAttribute("data-link-to") || "",
            sats: Number(form.getAttribute("data-link-sats") || "0"),
            path: path,
            message: form.getAttribute("data-link-message") || "",
            nonce: nonce,
            auto: silent
          })
        });
      }).then(function (pay) {
        if (!pay || !pay.ok || !pay.txid)
          throw new Error(pay && pay.error ? pay.error : "unpaid");
        setTxid(form, pay.txid);
        form.setAttribute("data-link-paid", "1");
        form.dispatchEvent(new CustomEvent("nn-gate-paid", { detail: { txid: pay.txid } }));
        if (form.getAttribute("data-link-post") === "1") {
          reveal(form);
          form.style.display = "";
          form.submit();
          return;
        }
        reveal(form);
      }).catch(function (err) {
        var msg = String((err && err.message) || "");
        if (msg.toLowerCase() === "confirm") {
          form._nnBusy = false;
          form._nnSilent = false;
          if (form._nnBtn) {
            form._nnBtn.style.display = "";
            form._nnBtn.disabled = false;
          }
          if (form._nnNote)
            form._nnNote.textContent = "Link will ask before another payment";
          return;
        }
        var why = reason(err);
        if (why === "No Link detected") {
          stay(form, why);
          return;
        }
        hold(form, why);
      });
    });
    if (autoOn(form)) {
      form._nnSilent = true;
      form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
    }
  });
})();
</script>