Overview of lnmessage implementation

LIVE #11August 17, 2023

In this live we look at the implementation of lnmessage JS library that let us send commando messages to a CLN node. Specifically, we look at LnMessage.commando and NoiseState.encryptMessage methods. This give us the opportunity to talk about BOLT #8 and BOLT #1.

Transcript with corrections and improvements

Note that if you don't know how to use lnmessage you might want to check first these videos:

Note that at 1h16m I wrote a wrong equality (that I don't reproduce here). The correct equality I wanted to write is:

ecdh(Alice.priv, Bob.pub) = ecdh(Bob.priv, Alice.pub)

getinfo.js

In the current directory we have the file package.json where we can see that lnmessage dependency is local and point to the lnmessage repository that we have cloned locally:

{
  "type": "module",
  "dependencies": {
    "lnmessage": "file:./lnmessage"
  }
}

We also have the Node JS script getinfo.js which:

  1. uses lnmessage library to connect to our node (that we are going to start in a moment),

  2. send a commando message to our node asking to run the command getinfo and

  3. print out the response that we get back from our node.

Note that we'll replace the dots when we'll start our node.

#!/usr/bin/env node

import LnMessage from 'lnmessage'
import net from 'net'

// node l1
const NODE_ID = '...'
const NODE_IP = '127.0.0.1'
const NODE_PORT = '7171'
// rune
const RUNE = '...'

const ln = new LnMessage({
  remoteNodePublicKey: NODE_ID,
  tcpSocket: new net.Socket(),
  ip: NODE_IP,
  port: NODE_PORT
})

await ln.connect()

const getinfo = await ln.commando({
  method: 'getinfo',
  params: [],
  rune: RUNE
})

console.log(getinfo)

process.exit() // ln.disconnect() if using lnmessage 0.2.6

Start 2 Lightning nodes running on regtest

Let's start two Lightning nodes running on the Bitcoin regtest chain by sourcing the script lightning/contrib/startup_regtest.sh provided in CLN repository and by running the command start_ln:

◉ tony@tony:~/clnlive:
$ source lightning/contrib/startup_regtest.sh
...
◉ tony@tony:~/clnlive:
$ start_ln
...

We can check that l1-cli is just an alias for lightning-cli with the base directory being /tmp/l1-regtest:

◉ tony@tony:~/clnlive:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'

Node id and rune

To get our script working correctly, we need l1's node id and a rune that authorizes us to run getinfo command using commando messages.

Let's get l1's node id

◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "02ba4bea6bc050a311fa2688d43821b907acfd50f714fdfa7296f97188cc854c54",
   "alias": "VIOLENTDEITY",
   "color": "02ba4b",
   "num_peers": 0,
   "num_pending_channels": 0,
   "num_active_channels": 0,
   "num_inactive_channels": 0,
   "address": [],
   "binding": [
      {
         "type": "ipv4",
         "address": "127.0.0.1",
         "port": 7171
      }
   ],
   "version": "v23.05.2",
   "blockheight": 1,
   "network": "regtest",
   "fees_collected_msat": 0,
   "lightning-dir": "/tmp/l1-regtest/regtest",
   "our_features": {
      "init": "08a0000a0269a2",
      "node": "88a0000a0269a2",
      "channel": "",
      "invoice": "02000002024100"
   }
}

and generates an unrestricted rune:

◉ tony@tony:~/clnlive:
$ l1-cli commando-rune
{
   "rune": "QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA==",
   "unique_id": "0",
   "warning_unrestricted_rune": "WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don't trust. Consider using the restrictions parameter to only allow access to specific rpc methods."
}

Now, we can set the variables NODE_ID and RUNE in the file getinfo.js with the values above:

...
// node l1
const NODE_ID = '02ba4bea6bc050a311fa2688d43821b907acfd50f714fdfa7296f97188cc854c54'
const NODE_IP = '127.0.0.1'
const NODE_PORT = '7171'
// rune
const RUNE = 'QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA=='
...

Run getinfo.js

Before we can run getinfo.js we need to build lnmessage library. To do that we open a new terminal and we move into the directory lnmessage. Then we install the dependencies and build the package:

# TERMINAL 2

◉ tony@tony:~/clnlive/lnmessage:
$ npm i
...
◉ tony@tony:~/clnlive/lnmessage:
$ npm run build
...

Back into the terminal 1, we can now install the dependencies by running:

◉ tony@tony:~/clnlive/lnmessage:
$ npm i
...

And we can check that lnmessage in node_modules is a symbolic link to the directory where we've clone and build lnmessage locally:

◉ tony@tony:~/clnlive:
$ ls -l node_modules/lnmessage
lrwxrwxrwx 1 tony tony 12 Aug 17 16:17 node_modules/lnmessage -> ../lnmessage/

Finally we can run getinfo.js script:

◉ tony@tony:~/clnlive:
$ ./getinfo.js
{
  id: '02ba4bea6bc050a311fa2688d43821b907acfd50f714fdfa7296f97188cc854c54',
  alias: 'VIOLENTDEITY',
  color: '02ba4b',
  num_peers: 1,
  num_pending_channels: 0,
  num_active_channels: 0,
  num_inactive_channels: 0,
  address: [],
  binding: [ { type: 'ipv4', address: '127.0.0.1', port: 7171 } ],
  version: 'v23.05.2',
  blockheight: 1,
  network: 'regtest',
  fees_collected_msat: 0,
  'lightning-dir': '/tmp/l1-regtest/regtest',
  our_features: {
    init: '08a0000a0269a2',
    node: '88a0000a0269a2',
    channel: '',
    invoice: '02000002024100'
  }
}

lnmessage connects getinfo.js to the node l1

One important thing to notice is that when we use lnmessage we connect the script to the node l1 and so getinfo.js in some way act as a peer.

Let's see that.

We comment the line process.exit() in getinfo.js to not keep connected connect to the node l1 after we've send the commando message.

Before we run again the script, in the terminal 2, we check that l1 has no peers:

◉ tony@tony:~/clnlive/lnmessage:
$ alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive/lnmessage:
$ l1-cli listpeers
{
    "peer": []
}

Now in the terminal 1, we run getinfo.js

◉ tony@tony:~/clnlive:
$ ./getinfo.js
{
  id: '02ba4bea6bc050a311fa2688d43821b907acfd50f714fdfa7296f97188cc854c54',
  alias: 'VIOLENTDEITY',
  color: '02ba4b',
  num_peers: 1,
  num_pending_channels: 0,
  num_active_channels: 0,
  num_inactive_channels: 0,
  address: [],
  binding: [ { type: 'ipv4', address: '127.0.0.1', port: 7171 } ],
  version: 'v23.05.2',
  blockheight: 1,
  network: 'regtest',
  fees_collected_msat: 0,
  'lightning-dir': '/tmp/l1-regtest/regtest',
  our_features: {
    init: '08a0000a0269a2',
    node: '88a0000a0269a2',
    channel: '',
    invoice: '02000002024100'
  }
}

and in the terminal 2, we check that the node l1 has a peer (which is getinfo.js):

◉ tony@tony:~/clnlive/lnmessage:
$ l1-cli listpeers
{
   "peers": [
      {
         "id": "027e29",
         "connected": true,
         "num_channels": 0,
         "netaddr": [
            "127.0.0.1:41568"
         ],
         "features": "0000000000000000"
      }
   ]
}

lnmessage

Let's start with an overview of lnmessage using cloc and tree utility:

◉ tony@tony:~/clnlive/lnmessage:
$ cd src/
◉ tony@tony:~/clnlive/lnmessage/src:
$ cloc .
      20 text files.
      20 unique files.
       0 files ignored.

github.com/AlDanial/cloc v 1.90  T=0.02 s (834.7 files/s, 125745.7 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
TypeScript                      20            390            790           1833
-------------------------------------------------------------------------------
SUM:                            20            390            790           1833
-------------------------------------------------------------------------------
◉ tony@tony:~/clnlive/lnmessage/src:
$ tree
.
├── chacha
│   ├── chacha20.ts
│   ├── index.ts
│   └── poly1305.ts
├── crypto.ts
├── index.ts
├── messages
│   ├── BigIntUtils.ts
│   ├── BitField.ts
│   ├── buf.ts
│   ├── CommandoMessage.ts
│   ├── InitFeatureFlags.ts
│   ├── InitMessage.ts
│   ├── IWireMessage.ts
│   ├── MessageFactory.ts
│   ├── PingMessage.ts
│   ├── PongMessage.ts
│   └── read-tlvs.ts
├── noise-state.ts
├── socket-wrapper.ts
├── types.ts
└── validation.ts

2 directories, 20 files

Terminal session

We ran the following commands in this order:

And below you can read the terminal session (command lines and outputs):

◉ tony@tony:~/clnlive:
$ source lightning/contrib/startup_regtest.sh
lightning-cli is /usr/local/bin/lightning-cli
lightningd is /usr/local/bin/lightningd
Useful commands:
  start_ln 3: start three nodes, l1, l2, l3
  connect 1 2: connect l1 and l2
  fund_nodes: connect all nodes with channels, in a row
  stop_ln: shutdown
  destroy_ln: remove ln directories
◉ tony@tony:~/clnlive:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
error code: -35
error message:
Wallet "default" is already loaded.
[1] 863417
[2] 863451
WARNING: eatmydata not found: install it for faster testing
Commands:
        l1-cli, l1-log,
        l2-cli, l2-log,
        bt-cli, stop_ln, fund_nodes
◉ tony@tony:~/clnlive:
$ l1-cli getinfo
{
   "id": "02ba4bea6bc050a311fa2688d43821b907acfd50f714fdfa7296f97188cc854c54",
   "alias": "VIOLENTDEITY",
   "color": "02ba4b",
   "num_peers": 0,
   "num_pending_channels": 0,
   "num_active_channels": 0,
   "num_inactive_channels": 0,
   "address": [],
   "binding": [
      {
         "type": "ipv4",
         "address": "127.0.0.1",
         "port": 7171
      }
   ],
   "version": "v23.05.2",
   "blockheight": 1,
   "network": "regtest",
   "fees_collected_msat": 0,
   "lightning-dir": "/tmp/l1-regtest/regtest",
   "our_features": {
      "init": "08a0000a0269a2",
      "node": "88a0000a0269a2",
      "channel": "",
      "invoice": "02000002024100"
   }
}
◉ tony@tony:~/clnlive:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive:
$ l1-cli commando-rune
{
   "rune": "QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA==",
   "unique_id": "0",
   "warning_unrestricted_rune": "WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don't trust. Consider using the restrictions parameter to only allow access to specific rpc methods."
}
◉ tony@tony:~/clnlive:
$ npm i

added 144 packages, and audited 146 packages in 6s

39 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
◉ tony@tony:~/clnlive:
$ ls -l node_modules/lnmessage
lrwxrwxrwx 1 tony tony 12 Aug 17 16:17 node_modules/lnmessage -> ../lnmessage/
◉ tony@tony:~/clnlive:
$ ./getinfo.js
{
  id: '02ba4bea6bc050a311fa2688d43821b907acfd50f714fdfa7296f97188cc854c54',
  alias: 'VIOLENTDEITY',
  color: '02ba4b',
  num_peers: 1,
  num_pending_channels: 0,
  num_active_channels: 0,
  num_inactive_channels: 0,
  address: [],
  binding: [ { type: 'ipv4', address: '127.0.0.1', port: 7171 } ],
  version: 'v23.05.2',
  blockheight: 1,
  network: 'regtest',
  fees_collected_msat: 0,
  'lightning-dir': '/tmp/l1-regtest/regtest',
  our_features: {
    init: '08a0000a0269a2',
    node: '88a0000a0269a2',
    channel: '',
    invoice: '02000002024100'
  }
}
◉ tony@tony:~/clnlive:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive:
$ ./getinfo.js
{
  id: '02ba4bea6bc050a311fa2688d43821b907acfd50f714fdfa7296f97188cc854c54',
  alias: 'VIOLENTDEITY',
  color: '02ba4b',
  num_peers: 1,
  num_pending_channels: 0,
  num_active_channels: 0,
  num_inactive_channels: 0,
  address: [],
  binding: [ { type: 'ipv4', address: '127.0.0.1', port: 7171 } ],
  version: 'v23.05.2',
  blockheight: 1,
  network: 'regtest',
  fees_collected_msat: 0,
  'lightning-dir': '/tmp/l1-regtest/regtest',
  our_features: {
    init: '08a0000a0269a2',
    node: '88a0000a0269a2',
    channel: '',
    invoice: '02000002024100'
  }
}
^C
◉ tony@tony:~/clnlive:
$ ./getinfo.js
19535
◉ tony@tony:~/clnlive:
$ ./getinfo.js
19535
LO�...�e��{"id":"lnmessage:getinfo#f411188f65dbe6b7","rune":"QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA==","method":"getinfo","params":[]}
◉ tony@tony:~/clnlive:
$ ./getinfo.js
19535
LO�ش...D...P{"id":"lnmessage:getinfo#a4d8b40b15440150","rune":"QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA==","method":"getinfo","params":[]}
03�M!���34�ٯ�//g�*nw�'�...j�g�...�...]��%��������ö3���...__
◉ tony@tony:~/clnlive:
$ ./getinfo.js
..."nF...LOpx��...O�{"id":"lnmessage:getinfo#7078eab7d6194ff5","rune":"QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA==","method":"getinfo","params":[]}
◉ tony@tony:~/clnlive:
$ ./getinfo.js
..."nF...LO�...Ù...!$S{"id":"lnmessage:getinfo#d612c39916212453","rune":"QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA==","method":"getinfo","params":[]}
◉ tony@tony:~/clnlive:
$ ./getinfo.js
foo
..."nF...foo
LO�h��"+�...{"id":"lnmessage:getinfo#a568fcbb222b8513","rune":"QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA==","method":"getinfo","params":[]}
◉ tony@tony:~/clnlive:
$ ./getinfo.js
foo
16
foo
19535
# TERMINAL 2

◉ tony@tony:~/clnlive/lnmessage:
$ npm i
...
◉ tony@tony:~/clnlive/lnmessage:
$ npm run build
...
◉ tony@tony:~/clnlive/lnmessage:
$ alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
◉ tony@tony:~/clnlive/lnmessage:
$ l1-cli listpeers
{
   "peer": []
}
◉ tony@tony:~/clnlive/lnmessage:
$ l1-cli listpeers
{
   "peers": [
      {
         "id": "027e29",
         "connected": true,
         "num_channels": 0,
         "netaddr": [
            "127.0.0.1:41568"
         ],
         "features": "0000000000000000"
      }
   ]
}
◉ tony@tony:~/clnlive/lnmessage:
$ cd src/
◉ tony@tony:~/clnlive/lnmessage/src:
$ cloc .
      20 text files.
      20 unique files.
       0 files ignored.

github.com/AlDanial/cloc v 1.90  T=0.02 s (834.7 files/s, 125745.7 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
TypeScript                      20            390            790           1833
-------------------------------------------------------------------------------
SUM:                            20            390            790           1833
-------------------------------------------------------------------------------
◉ tony@tony:~/clnlive/lnmessage/src:
$ tree
.
├── chacha
│   ├── chacha20.ts
│   ├── index.ts
│   └── poly1305.ts
├── crypto.ts
├── index.ts
├── messages
│   ├── BigIntUtils.ts
│   ├── BitField.ts
│   ├── buf.ts
│   ├── CommandoMessage.ts
│   ├── InitFeatureFlags.ts
│   ├── InitMessage.ts
│   ├── IWireMessage.ts
│   ├── MessageFactory.ts
│   ├── PingMessage.ts
│   ├── PongMessage.ts
│   └── read-tlvs.ts
├── noise-state.ts
├── socket-wrapper.ts
├── types.ts
└── validation.ts

2 directories, 20 files

Source code

getinfo.js

#!/usr/bin/env node

import LnMessage from 'lnmessage'
import net from 'net'

// node l1
const NODE_ID = '02ba4bea6bc050a311fa2688d43821b907acfd50f714fdfa7296f97188cc854c54'
const NODE_IP = '127.0.0.1'
const NODE_PORT = '7171'
// rune
const RUNE = 'QoPDe_gQX_ejD7N40QNnLUuJmIioAoxr53x9QLDfTIs9MA=='

const ln = new LnMessage({
  remoteNodePublicKey: NODE_ID,
  tcpSocket: new net.Socket(),
  ip: NODE_IP,
  port: NODE_PORT
})

await ln.connect()

const getinfo = await ln.commando({
  method: 'getinfo',
  params: [],
  rune: RUNE
})

// console.log(getinfo)

process.exit() // ln.disconnect()

package.json

{
  "type": "module",
  "dependencies": {
    "lnmessage": "file:./lnmessage"
  }
}

LnMessage.commando()

class LnMessage {
  ...
  async commando({
    method,
    params = [],
    rune,
    reqId
  }: CommandoRequest): Promise<JsonRpcSuccessResponse['result']> {
    this._log('info', `Commando request method: ${method} params: ${JSON.stringify(params)}`)

    // not connected, so initiate a connection
    if (this.connectionStatus$.value === 'disconnected') {
      this._log('info', 'No socket connection, so creating one now')

      const connected = await this.connect()

      if (!connected) {
        throw {
          code: 2,
          message: 'Could not establish a connection to node'
        }
      }
    } else {
      this._log('info', 'Ensuring we have a connection before making request')

      // ensure that we are connected before making any requests
      await firstValueFrom(this.connectionStatus$.pipe(filter((status) => status === 'connected')))
    }

    const writer = new BufferWriter()

    if (!reqId) {
      // create random id to match request with response
      const id = createRandomBytes(8)
      reqId = bytesToHex(id)
    }

    // write the type
    writer.writeUInt16BE(MessageType.CommandoRequest)

    // write the id
    writer.writeBytes(Buffer.from(reqId, 'hex'))

    // Unique request id with prefix, method and id
    const detailedReqId = `lnmessage:${method}#${reqId}`

    // write the request
    writer.writeBytes(
      Buffer.from(
        JSON.stringify({
          id: detailedReqId, // Adding id for easier debugging with commando
          rune,
          method,
          params
        })
      )
    )

    this._log('info', 'Creating message to send')
    const message = this.noise.encryptMessage(writer.toBuffer())

    if (this.socket) {
      this._log('info', 'Sending commando message')
      this.socket.send(message)

      this._log('info', `Message sent with id ${detailedReqId} and awaiting response`)

      const { response } = await firstValueFrom(
        this._commandoMsgs$.pipe(filter((commandoMsg) => commandoMsg.id === reqId))
      )

      const { result } = response as JsonRpcSuccessResponse
      const { error } = response as JsonRpcErrorResponse

      this._log(
        'info',
        result
          ? `Successful response received for ID: ${response.id}`
          : `Error response received: ${error.message}`
      )

      if (error) throw error

      return result
    } else {
      throw new Error('No socket initialised and connected')
    }
  }
  ...
}

commando_msgtype

/* We (as your local commando command) detected an error. */
#define COMMANDO_ERROR_LOCAL 0x4c4f
/* Remote (as executing your commando command) detected an error. */
#define COMMANDO_ERROR_REMOTE 0x4c50
/* Specifically: bad/missing rune */
#define COMMANDO_ERROR_REMOTE_AUTH 0x4c51

enum commando_msgtype {
        /* Requests are split across multiple CONTINUES, then TERM. */
        COMMANDO_MSG_CMD_CONTINUES = 0x4c4d,
        COMMANDO_MSG_CMD_TERM = 0x4c4f,
        /* Replies are split across multiple CONTINUES, then TERM. */
        COMMANDO_MSG_REPLY_CONTINUES = 0x594b,
        COMMANDO_MSG_REPLY_TERM = 0x594d,
};

NoiseState.encryptMessage()

export class NoiseState {
  ...
  /**
   * Sends an encrypted message using the shared sending key and nonce.
   * The nonce is rotated once the message is sent. The sending key is
   * rotated every 1000 messages.
   * @param m
   */
  public encryptMessage(m: Buffer): Buffer {
    // step 1/2. serialize m length into int16
    const l = Buffer.alloc(2)
    l.writeUInt16BE(m.length, 0)
    // step 3. encrypt l, using chachapoly1305, sn, sk)
    const lc = ccpEncrypt(this.sk, this.sn, Buffer.alloc(0), l)
    // step 3a: increment sn
    if (this._incrementSendingNonce() >= 1000) this._rotateSendingKeys()
    // step 4 encrypt m using chachapoly1305, sn, sk
    const c = ccpEncrypt(this.sk, this.sn, Buffer.alloc(0), m)
    // step 4a: increment sn
    if (this._incrementSendingNonce() >= 1000) this._rotateSendingKeys()
    // step 5 return m to be sent
    return Buffer.concat([lc, c])
  }
  ...
}

Resources