SI developer guide
Evidence level: Implemented capability. This page documents how an application talks to a live CoNET-SI node. The command names, POST body, and encryption targets match current CoNET-SI and Beamio clients. Availability of any one Guardian domain is deployment-specific.
Public site: https://gitbook.conet.network/l0/si-developer-guide.html
CoNET-SI is the Layer Minus service node. It accepts OpenPGP armor on POST /post, reads the recipient key ID, and either forwards the armor or decrypts once when the key is local. It does not implement Chat, VPN, payments, or history. Those are application compositions.
Use this page to build a client against SI. Developer-track index: L0 development. For a Chat product on top of the same plane, continue with the Chat developer guide.
What you are calling
| Piece | Role |
|---|---|
| CoNET-SI | HTTP/HTTPS entry, SI-to-SI HTTP :80 forward, mailbox store, SSE listen pools, presence, delivery ACK, UDP relay, SilentPass commands |
| AddressPGP | L1 binding of an EOA to user PGP and a mailbox route key |
| GuardianNodesInfoV6 | Public node list: domain, IP, route public key |
| Your application | Wallet, OpenPGP keys, inner JSON schema, UI |
Public node source: CoNET-project/CoNET-SI · npm @conet.project/mvp-si.
Wire contract
Every client request is the same shape:
POST /post HTTP/1.1
Host: {domain}.conet.network
Content-Type: application/json
{"data":"<OpenPGP armored message>"}
Optional field on the JSON object (not inside the armor):
| Field | Meaning |
|---|---|
data |
Required. Full OpenPGP armor (-----BEGIN PGP MESSAGE----- …) |
beamioNoPush |
Optional true. Skip APNs / offline push for this delivery |
Client → entry may use HTTP or HTTPS. HTTPS is common in browsers because mixed content forbids http:// from an https:// page. HTTP is sufficient for confidentiality: the body is already OpenPGP ciphertext. SI → SI forwarding is HTTP on port 80 only. Do not treat an entry TLS certificate problem as “the DePIN mesh is down.”
Typical URLs:
https://{domain}.conet.network/post
http://{domain}.conet.network/post
Do not invent a new hostname. Use the Guardian domain from getAllNodes. Do not default-dial mailbox B for Chat, listen, ACK, presence, or UDP. Pick a healthy entry A or C with A ≠ B and C ≠ B. LayerMinus mining collectors are the documented exception: they dial the target SI directly.
A 404 with a body such as body has not PGP message means SI rejected invalid armor. That is an intentional reject, not proof that the node process is down.
HTTP 200 on send, or an SSE handshake on listen, is transport progress. It is not proof that an application decrypted, verified, or rendered the payload.
Two payload families
Do not mix encryption targets.
1. Opaque business armor (SI forwards, does not decrypt)
Encrypt to the recipient’s user PGP. SI only sees the OpenPGP key ID and routes to that key’s mailbox.
Typical use: Chat text, typed application JSON, udp_subscribe (contains the AES key), sender delivery receipts.
application object
→ optional EIP-191 envelope (Chat uses this)
→ base64(JSON)
→ OpenPGP encrypt to R userPublicKeyArmored
→ POST { data } to entry A ≠ B
2. Signed SI command (mailbox B decrypts)
Encrypt { message, signMessage } to B’s route PGP. message is JSON.stringify(command). signMessage is EIP-191 wallet.signMessage(message). SI checkSign recovers walletAddress and must match the signer.
That walletAddress is the routing EOA (isMyRoute, listen pool, last-hop GB). It does not have to be the sender or recipient EOA inside a user-PGP Chat body. Apps that want a stronger split register AddressPGP on a dedicated routing wallet. See wallet-addressed peer identity.
Typical use: Chat listen, mining listen, gossip_delivery_ack, wallet_online_query, UDP listen/relay (no Securitykey), SilentPass / SOCKS commands.
command object (includes walletAddress)
→ message = JSON.stringify(command)
→ signMessage = personal_sign(message)
→ base64(JSON.stringify({ message, signMessage }))
→ OpenPGP encrypt to B route public key
→ POST { data } to entry C ≠ B
Live command catalog
Source: CoNET-SI localNodeCommandSocket. Encrypt the command family to route PGP unless the table says otherwise.
command |
Encrypt to | HTTP / SSE | Notes |
|---|---|---|---|
mining + listenKind: "chat" |
Own mailbox B route PGP | Long SSE via entry C ≠ B | Chat / Merchant OS / Alliance mailbox. Required fields: walletAddress, algorithm: "aes-256-cbc", Securitykey (session key) |
mining (omit listenKind) |
Target SI route PGP | Infrastructure SSE | LayerMinus mining. SI defaults listenKind to "mining". Not a Chat shortcut |
gossip_delivery_ack |
B route PGP | Entry C ≠ B | After the client ingested user-PGP armor. Fields: walletAddress, armorHash (keccak256(utf8(full armor))), timestamp (unix seconds, ±600s), optional sendId |
wallet_online_query |
Contact’s mailbox B route PGP | Entry C ≠ B | Presence. Fields: walletAddress (signer), targetWallet, timestamp (±600s). Success: { ok: true, wallet, online, listenAgeMs, nodeWallet }. Do not use chain routeOnline |
udp_subscribe |
UDP server user PGP | Entry A ≠ B | Contains Securitykey. SI rejects encryption to B (encrypt_to_udp_server_user_pgp) |
udp_listen / udp_server_listen / udp_relay / udp_uplink / udp_unlisten |
B route PGP | Entry ≠ B | No Securitykey. See UDP frame forwarding |
SilentPass / SaaS_Sock5 / SaaS_Sock5_v2 |
Egress node route PGP | Product-specific | Paid proxy; not a Chat path |
Old clients that omit listenKind on mining are treated as mining. A Chat client must send listenKind: "chat" so SI does not apply mining-only pool policy to the mailbox SSE.
SI hop behavior (do not fight it)
When SI forwards to another SI it may append X-CoNET-Hop-Sigs (base64 JSON, max 3 EIP-191 hop signatures). Application clients do not set this header on the first /post.
After a local decrypt:
- if the plaintext is still OpenPGP for the same node, SI treats it as an attack, emits socket
end, and does not peel again; - if the inner key ID is another node, SI forwards the inner armor when hop-sig count can still grow (cap 3);
- more than 3 hop signatures, or a count that cannot take another hop, is an all-node flood:
end, no forward; - if the destination SI emits
end, the previous hop closes and frees that socket; - on a signed command path, the last hop may add verified prior-hop bytes to that wallet’s gossip GB meter. A mailbox store of user-PGP armor (no command decrypt) cannot charge the user.
Optional outer wrap: encrypt a user-PGP business message to an entry route key so the first /post observer sees the outer key ID. Each peel node still learns the next key ID. This is not a mix network. See security limits.
Constants
| Item | Value |
|---|---|
| CoNET L1 | chainId 224422 |
| Read RPC | https://rpc1.conet.network (primary), https://publicrpc.conet.network (backup). Do not use deprecated rpc.conet.network |
| AddressPGP | 0x684b0ac760cEE9c9b85de36d69746420648Cf9e2 |
| GuardianNodesInfoV6 | 0xBC6b53065b5647261396d002bDBA0d3396E0722f |
| Compatibility register API | POST https://beamio.app/api/regiestChatRoute (spelling is live) |
Sample: discover Guardian nodes
import { ethers } from 'ethers'
const CONET_RPC = 'https://rpc1.conet.network'
const GUARDIAN_NODES = '0xBC6b53065b5647261396d002bDBA0d3396E0722f'
const guardianAbi = [
'function getAllNodes(uint256 start, uint256 length) view returns (tuple(uint256 id, string PGP, string PGPKey, string ip_addr, string regionName)[])',
]
export type SiNode = {
nftNumber: number
armoredPublicKey: string
domain: string
ip_addr: string
region: string
}
export async function listGuardianNodes(): Promise<SiNode[]> {
const provider = new ethers.JsonRpcProvider(CONET_RPC)
const c = new ethers.Contract(GUARDIAN_NODES, guardianAbi, provider)
const pages = await Promise.all([c.getAllNodes(0, 400), c.getAllNodes(400, 800)])
const out: SiNode[] = []
for (const row of pages.flat()) {
// Live clients: PGP = base64 route public key; PGPKey = Guardian domain.
const armored = Buffer.from(String(row.PGP), 'base64').toString('utf8')
const domain = String(row.PGPKey)
if (!domain || !armored.includes('BEGIN PGP')) continue
out.push({
nftNumber: Number(row.id),
armoredPublicKey: armored,
domain,
ip_addr: String(row.ip_addr),
region: String(row.regionName),
})
}
return out
}
export function postUrl(domain: string, https = true): string {
return `${https ? 'https' : 'http'}://${domain}.conet.network/post`
}
Pick entries that are not the recipient mailbox domain. Keep a small health set: mark a domain bad on timeout / non-2xx, prefer previously healthy domains, then retry another wave.
Sample: resolve AddressPGP
const ADDRESS_PGP = '0x684b0ac760cEE9c9b85de36d69746420648Cf9e2'
const pgpAbi = [
'function searchKey(address to) view returns (string userPgpKeyID, string userPublicKeyArmored, string routePgpKeyID, string routePublicKeyArmored, bool routeOnline)',
]
export type AddressPgpRecord = {
userPgpKeyID: string
userPublicKeyArmored: string
routePgpKeyID: string
routePublicKeyArmored: string
}
export async function searchAddressPgp(eoa: string): Promise<AddressPgpRecord | null> {
const provider = new ethers.JsonRpcProvider(CONET_RPC)
const c = new ethers.Contract(ADDRESS_PGP, pgpAbi, provider)
const r = await c.searchKey(ethers.getAddress(eoa))
const userArmored = Buffer.from(String(r.userPublicKeyArmored), 'base64').toString('utf8')
const routeArmored = Buffer.from(String(r.routePublicKeyArmored), 'base64').toString('utf8')
if (!userArmored.includes('BEGIN PGP')) return null
return {
userPgpKeyID: String(r.userPgpKeyID),
userPublicKeyArmored: userArmored,
routePgpKeyID: String(r.routePgpKeyID),
routePublicKeyArmored: routeArmored,
}
// Ignore routeOnline. Presence is mailbox listen-pool only (wallet_online_query).
}
Encrypt business armor to userPublicKeyArmored of the recipient EOA. An AA Smart Wallet is not a destination unless it has its own AddressPGP row.
Sample: register a mailbox route
Current clients generate an ECC OpenPGP key, take the encryption subkey ID (getKeyIDs()[1], uppercase hex), choose a Guardian domain as mailbox, and call the compatibility API. The live field name is routeKeyID; clients pass that domain, not the hex key ID.
import { generateKey } from 'openpgp'
export async function generateUserPgp() {
const { privateKey, publicKey } = await generateKey({
type: 'ecc',
curve: 'curve25519',
userIDs: [{ name: 'conet', email: 'conet@localhost' }],
format: 'armored',
})
const { readKey } = await import('openpgp')
const keyObj = await readKey({ armoredKey: publicKey })
const keyID = keyObj.getKeyIDs()[1].toHex().toUpperCase()
return { privateKey, publicKey, keyID }
}
export async function registerChatRoute(opts: {
eoaPrivateKey: string
publicKeyArmored: string
keyID: string
encrypKeyArmored: string
mailboxDomain: string
}): Promise<boolean> {
const wallet = new ethers.Wallet(opts.eoaPrivateKey)
const res = await fetch('https://beamio.app/api/regiestChatRoute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wallet: wallet.address,
keyID: opts.keyID,
publicKeyArmored: Buffer.from(opts.publicKeyArmored, 'utf8').toString('base64'),
encrypKeyArmored: opts.encrypKeyArmored,
routeKeyID: opts.mailboxDomain,
}),
})
const json = await res.json().catch(() => ({}))
return res.ok && !!json?.ok
}
encrypKeyArmored is an application backup of the user PGP private key (current clients AES-GCM encrypt it with material derived from the EOA). Do not log the EOA key, mnemonic, PGP private armor, Securitykey, or full hop signatures.
After register, wait and re-read searchKey(eoa) until userPgpKeyID matches the local subkey ID.
Sample: encrypt a signed SI command
import { createMessage, encrypt, enums, readKey } from 'openpgp'
import { ethers } from 'ethers'
export async function encryptRouteCommand(
wallet: ethers.Wallet,
command: Record<string, unknown>,
routePublicKeyArmored: string,
): Promise<string> {
const message = JSON.stringify(command)
const signMessage = await wallet.signMessage(message)
const literal = Buffer.from(JSON.stringify({ message, signMessage })).toString('base64')
const pgpMsg = await createMessage({ text: literal })
const encryptionKeys = await readKey({ armoredKey: routePublicKeyArmored })
return encrypt({
message: pgpMsg,
encryptionKeys,
config: { preferredCompressionAlgorithm: enums.compression.zlib },
})
}
export async function postArmor(
domain: string,
armored: string,
opts?: { https?: boolean; beamioNoPush?: boolean; acceptSse?: boolean; signal?: AbortSignal },
): Promise<Response> {
const body: { data: string; beamioNoPush?: boolean } = { data: armored }
if (opts?.beamioNoPush) body.beamioNoPush = true
return fetch(postUrl(domain, opts?.https !== false), {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=UTF-8',
...(opts?.acceptSse ? { Accept: 'text/event-stream' } : {}),
},
body: JSON.stringify(body),
signal: opts?.signal,
cache: 'no-store',
})
}
Chat mailbox listen (listenKind: "chat")
export async function openChatListen(opts: {
wallet: ethers.Wallet
ownRoutePublicKeyArmored: string
mailboxDomain: string
entryDomain: string
signal?: AbortSignal
}): Promise<Response> {
if (opts.entryDomain === opts.mailboxDomain) {
throw new Error('entry C must not be mailbox B')
}
const Securitykey = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64')
const armored = await encryptRouteCommand(
opts.wallet,
{
command: 'mining',
listenKind: 'chat',
walletAddress: opts.wallet.address,
algorithm: 'aes-256-cbc',
Securitykey,
},
opts.ownRoutePublicKeyArmored,
)
const res = await postArmor(opts.entryDomain, armored, { acceptSse: true, signal: opts.signal })
if (!res.ok || !res.body) throw new Error(`listen HTTP ${res.status}`)
return res
}
Read res.body as a byte stream. First frames are often a handshake or mining-shaped { status, epoch, … } liveness listing. Those are not user-PGP business messages. A browser console line [Gossip] Unknown format: {status, epoch…} is that heartbeat. Keep the SSE open; reconnect on idle / drop with another random C ≠ B. Production clients use a setTimeout chain, not setInterval.
Presence query
export async function queryMailboxOnline(opts: {
wallet: ethers.Wallet
targetWallet: string
targetRoutePublicKeyArmored: string
mailboxDomain: string
entryDomain: string
}): Promise<{ ok: boolean; online: boolean; error?: string }> {
if (opts.entryDomain === opts.mailboxDomain) {
throw new Error('entry C must not be mailbox B')
}
const armored = await encryptRouteCommand(
opts.wallet,
{
command: 'wallet_online_query',
walletAddress: opts.wallet.address,
targetWallet: ethers.getAddress(opts.targetWallet),
timestamp: Math.floor(Date.now() / 1000),
},
opts.targetRoutePublicKeyArmored,
)
const res = await postArmor(opts.entryDomain, armored)
const json = await res.json().catch(() => ({ ok: false, online: false, error: 'parse' }))
return json
}
Treat only ok === true as trusted. On timeout or not_my_route, keep the last trusted online value. Do not write chain routeOnline into the UI.
Mailbox delivery ACK
export function hashPgpArmor(fullArmor: string): string {
return ethers.keccak256(ethers.toUtf8Bytes(fullArmor))
}
export async function postMailboxDeliveryAck(opts: {
wallet: ethers.Wallet
ownRoutePublicKeyArmored: string
mailboxDomain: string
entryDomain: string
armorHash: string
sendId?: string
}): Promise<boolean> {
const armored = await encryptRouteCommand(
opts.wallet,
{
command: 'gossip_delivery_ack',
walletAddress: opts.wallet.address,
armorHash: opts.armorHash,
timestamp: Math.floor(Date.now() / 1000),
...(opts.sendId ? { sendId: opts.sendId } : {}),
},
opts.ownRoutePublicKeyArmored,
)
const res = await postArmor(opts.entryDomain, armored)
return res.ok
}
armorHash is keccak256 of the complete inbound PGP armor string, 0x + 64 hex.
Sample: post opaque user-PGP armor
export async function encryptUserPayload(
plaintextUtf8: string,
recipientUserPublicKeyArmored: string,
): Promise<string> {
const pgpMsg = await createMessage({
text: Buffer.from(plaintextUtf8, 'utf8').toString('base64'),
})
const encryptionKeys = await readKey({ armoredKey: recipientUserPublicKeyArmored })
return encrypt({
message: pgpMsg,
encryptionKeys,
config: { preferredCompressionAlgorithm: enums.compression.zlib },
})
}
export async function postUserArmorToEntries(
armored: string,
entryDomains: string[],
mailboxDomain: string,
): Promise<boolean> {
const targets = entryDomains.filter((d) => d && d !== mailboxDomain).slice(0, 4)
const results = await Promise.all(
targets.map(async (domain) => {
try {
const res = await postArmor(domain, armored)
return res.ok
} catch {
return false
}
}),
)
return results.some(Boolean)
}
Chat wraps an EIP-191 envelope before this encrypt step. See the Chat developer guide.
Optional outer wrap (one extra hop)
To hide the inner user-PGP key ID from the first /post observer, encrypt the already-built user-PGP armor to an entry route public key, then POST that outer armor to that same entry. SI decrypts once and forwards the inner armor if the inner key is not local. Do not wrap so the inner key is again this node. Do not build a hop chain longer than SI will accept (cap 3 SI-to-SI signatures).
Dependencies
{
"dependencies": {
"ethers": "^6.13.0",
"openpgp": "^5.11.0"
}
}
Node samples above use Buffer. In browsers use btoa / atob or a UTF-8 helper. Run OpenPGP encrypt/decrypt off the UI thread (Web Worker) if the page must stay responsive.
Checklist
- [ ] POST body is
{ data: <armor> }to{domain}.conet.network/post - [ ] Business encrypt-to user PGP; commands encrypt-to route PGP
- [ ] Chat listen includes
listenKind: "chat"and uses C ≠ B - [ ] Send / ACK / presence / UDP do not default-dial mailbox B
- [ ] EIP-191
signMessagecovers the exactmessagestring SI will verify - [ ] Failures do not log private keys, full PGP private armor, or
Securitykey - [ ] HTTP 200 / SSE Connected is not treated as application delivery
- [ ] Presence uses
wallet_online_query, notsearchKey.routeOnline