Multi-Signature Architecture and Code Review

It Can't Get More Technical

Screenshot from 2021-04-19 18-09-41.png

I'll be sharing some code that helps a decentralized network to execute Multi-Signature control over Funds. Buckle Up!

First: Building manual transactions requires a few things that usually get done automatically. All the nodes will need to agree on which is the reference block as well as an expiration time. To do this the block processor was modified in the following ways: processor.js

+   var block_header;

client.database.getBlock(blockNum)
  .then((result) => {

+   block_header = {
+       timestamp: result.timestamp,
+       block_id: result.block_id,
+       block_number: blockNum
+   }

return {

+   getBlockHeader: function() {
+       return block_header;
+   },

}

This allows us to query these parameters and send them along in our reports : index.js

  if (num % 100 === 50 && processor.isStreaming()) {
+   plasma.bh = processor.getBlockHeader()
    report(plasma)
        .then(nodeOp => {
            NodeOps.unshift(nodeOp)
        })
    .catch(e => { console.log(e) })
                    }

These are added to the report made in report.js It's important to know that this code builds a transaction and sends it to Hive. Hive is where crosstalk occurs, this happens once per node, and processing_routes/report.js is triggered for every node running the token ecosystem.

+   ms: {
+       exp: new Date((new Date(plasma.bh.timestamp).getTime()) + 86400000).toISOString().slice(0, -5),
+       rbn: plasma.ref_block_number,
+       rbp: Buffer.from(plasma.bh.block_id, 'hex').readUInt32LE(4)
+   }

Now we can count these in tally.js so our signatories can build the exact same operations to sign. This is where all the reports are counted to determine consensus every 100 blocks. The following code is put in after the election:

var possible_ms_headers = {
    e:{}, //expiration
    n:{}, //number
    p:{}, //prefix
    ec: 0, //counters
    pc: 0,
    nc: 0
 }
for (node in still_running) {
    if(!possible_ms_headers.e[nodes[node].report.ms.exp]){
        possible_ms_headers.e[nodes[node].report.ms.exp] = 1
    } else {
        possible_ms_headers.e[nodes[node].report.ms.exp]++
    }
    if(!possible_ms_headers.n[nodes[node].report.ms.rbn]){
        possible_ms_headers.n[nodes[node].report.ms.rbn] = 1
    } else {
        possible_ms_headers.n[nodes[node].report.ms.rbn]++
    }
    if(!possible_ms_headers.p[nodes[node].report.ms.rbp]){
        possible_ms_headers.p[nodes[node].report.ms.rbp] = 1
    } else {
        possible_ms_headers.p[nodes[node].report.ms.rbp]++
    }
 }
for(opt in possible_ms_headers.e){
    if(possible_ms_headers.e[opt] > possible_ms_headers.ec){
        stats.ms_exp = opt
        possible_ms_headers.ec = possible_ms_headers.e[opt]
    }
 }
for(opt in possible_ms_headers.p){
    if(possible_ms_headers.p[opt] > possible_ms_headers.pc){
        stats.ms_ref_prefix = opt
        possible_ms_headers.pc = possible_ms_headers.p[opt]
    }
 }
for(opt in possible_ms_headers.n){
    if(possible_ms_headers.n[opt] > possible_ms_headers.nc){
        stats.ms_ref_num = opt
        possible_ms_headers.nc = possible_ms_headers.n[opt]
    }
 }

Now we have everything we need to deterministically sign new transactions.
Let's start by building an account_update transaction so we can change the shared accounts authorized public keys, we'll want to do this whenever the safetyLimit goes up by a margin or there's a greater than 25% change in who's in the governance group.

//add public keys
var auths = []
for (node in still_running){
    auths.push([nodes[node].pubKey,1])
}

//build account update
var tx = [
  "account_update",
  {
    "account": config.msaccount,
    "active": {
      "weight_threshold": parseInt(still_running.length/2) + 1,
      "account_auths": [],
      "key_auths": auths
    },
    "owner": {
      "weight_threshold": parseInt(still_running.length/2) + 1,
      "account_auths": [],
      "key_auths": auths
    },
    "json_metadata": ""
  }
]

var txref = ["custom_json", {
            required_auths: [config.msaccount],
            required_posting_auths: [],
            id: `${config.prefix}ms_signed`,
            json: JSON.stringify({
                op: `${num}:${stats.ms_ref_num}:${stats.ms_ref_prefix}`
            })
        }]

Now we can get each node to sign this transaction:

msop = {
    ref_block_num: stats.ms_exp,
    ref_block_prefix: stats.ms_ref_prefix,
    expiration: stats.ms_ref_num,
    operations: [tx, txref],
    extensions: [],
}

Store this so we can verify signatures later. It's important to remember that anything that's unique to the node has to be talked into state via a Hive transaction.

ops.push({ type: 'put', path: ['ms', 'ops', `${num}:${stats.ms_ref_num}:${stats.ms_ref_prefix}`], data: JSON.stringify(msop) }) //blocknumber, since we can only send one per block anyway

If the node is in the runners group we'll need it to sign the transaction and submit the signature

if(agents[nodes[config.username].pubKey] > 0){
    const stx = client.broadcast.sign(msop, privateKey)
    var op = ["custom_json", {
            required_auths: [config.username],
            required_posting_auths: [],
            id: `${config.prefix}sig_submit`,
            json: JSON.stringify({
                op: `${num}:${stats.ms_ref_num}:${stats.ms_ref_prefix}`,
        sig: stx.signatures[0]
            })
        }];
    NodeOps.push([[0, 0], op]) //off to get broadcasted by the node
} 

In about a minute we'll start processing these submitted signatures.

exports.sig_submit = (json, from, active, pc) => {
    if(active){
        var Pop = getPathObj(['ms', 'ops', json.op]),
            Pstats = getPathObj(['stats']),
            Pagents = getPathObj(['agents'])
    Promise.all([Pop, Pstats, Pagents])
        .then(got => {
            let msop = JSON.parse(got[0]),
                stats = got[1],
                agents = got[2],
                ops = [],
                op = {
                    expiration: msop.expiration,
                    extensions: msop.extensions,
                    operations: msop.operations,
                    ref_block_num: msop.ref_block_num,
                    ref_block_prefix: msop.ref_block_prefix,
                }
            if(signedBy(op, json.sig, agents)){
                msop.signatures.push(json.sig) //add signatures to the msop
                if(msop.signatures.length === stats.auth_at){
                    //broadcast tx
                    if(config.username === config.leader){
                        broadcast(msop)
                            .then(r=>console.log('Multi-Sig Send Success\n', r))
                            .catch(e=>console.log('Multi-Sig Send Failure\n', e))
                    }
                    //either way we're expecting the transaction to be broadcast, so all the nodes will schedule themselves to look.
                    msop.chron = chronAssign(json.block_num + 30, {
                        block: json.block_num + 30,
                        op: 'ms_send',
                        attempts: JSON.stringify([config.leader]),
                        txid: json.op
                    })
                }
                ops.push({ type: 'put', path: ['ms', 'ops', json.op], data: JSON.stringify(msop) })
                store.batch(ops, pc);
            } else {
                pc[0](pc[2])
            }
        })
        .catch(e => { console.log(e); });
    } else {
        pc[0](pc[2])
    }
}

function signedBy(op, Sig, keys){
    let signed = false
    const sig = Signature.fromString(Sig);
    const digest = cryptoUtils.transactionDigest(op);
    const key = (new Signature(sig.data, sig.recovery)).recover(digest);
    const publicKey = key.toString()
    for(i in keys){
        if(i === publicKey){
            signed = i
            break
        }    
    }
    return signed
}

function broadcast(op){
    return new Promise((resolve, reject) => {
        const client = new dhive.Client(config.clientURL);

        client.broadcast.send(stx)
            .then(r=>resolve(r)) //I guess log this?
            .catch(e=>reject(e)) //loop different API?
    })
}

As we can see above: once enough signatures are collected the leader broadcasts the transaction.

We'll discuss a failure there first. This code will be triggered in 30 blocks as set in ChronAssign above

const recast = (attempts, txid, bn) => {
    return new Promise((resolve, reject) => {
        var tries = JSON.parse(attempts)
        var Pop = getPathObj(['ms', 'ops', txid]),
            Pstats = getPathObj(['stats']),
            Pagents = getPathObj(['agents'])
    Promise.all([Pop, Pstats, Pagents])
        .then(got => {
            let msop = JSON.parse(got[0]),
                stats = got[1],
                agents = got[2],
                ops = [],
                sigsA = [],
                auths = 0,
                op = {
                    expiration: msop.expiration,
                    extensions: msop.extensions,
                    operations: msop.operations,
                    ref_block_num: msop.ref_block_num,
                    ref_block_prefix: msop.ref_block_prefix,
                }
                for (sig in msop.signatures){
                    if(signedBy(op, msop.signatures[sig], agents)){
                        auths++
                        sigsA.push(msop.signatures[sig]) //edgecase authorized signature removed
                    }
                }
                if(auths < stats.auth_at){
                    ops.push({ type: 'put', path: ['feed', `${bn}:v_op`], data: `Multi-Sig Failure at Recast: Insufficient Signatures` })
                } else {
                msop.signatures = sigsA
                broadcast(msop)
                    .then(r=>console.log('Multi-Sig Send Success\n', r))
                    .catch(e=>console.log('Multi-Sig Send Failure\n', e))
                msop.chron = chronAssign(bn + 30, {
                        block: bn + 30,
                        op: 'ms_send',
                        attempts: JSON.stringify(tries),
                        txid: txid
                    })
                
                ops.push({ type: 'put', path: ['ms', 'ops', txid], data: JSON.stringify(msop) })
                }
                store.batch([{ type: 'put', path: ['chrono', t], data: op }], [resolve, reject, t])
            })
    })
}

This will let everybody attempt to send the transaction. And if the account has been updated in the meantime some of these signatures won't be valid, or the weight threshold may have changed. This should fix either of these outcomes as well.

Finally. The actual MS OPS!

exports.account_update = (json, pc) => { //ensure proper keys are on record for DAO accounts
        let agentsP = getPathObj(['agents']),
            statsP = getPathObj(['stats']),
            keysP = getPathObj(['markets', 'node', json.account, 'pubKey'])
        Promise.all([agentsP, statsP, keysP, runnersP])
            .then(a => {
                let agents = a[0],
                    stats = a[1],
                    keyPair = a[2],
                    ops = []
                if (json.account == config.msaccount && json.active != null) { //update agents
                    for (agent in agents) { //list of public keys ever used with the current weight
                        agents[agent].o = 0 //turn all weights to 0
                        agents[agent].a = 0 //owner active weights
                    }
                    if(json.active !== undefined){
                        for (i = 0; i < json.active.key_auths.length; i++) {
                            stats.auth[json.active.key_auths[i][0]] = json.active.key_auths[i][1]
                            if(agents[keyPairs[json.active.key_auths[i][0]]] !== undefined){
                                agents[keyPairs[json.active.key_auths[i][0]]] = {}
                            }
                            agents[keyPairs[json.active.key_auths[i][0]]].a = json.active.key_auths[i][1]
                        }
                        stats.auth_at = json.active.weight_threshold
                    }
                    if(json.owner !== undefined){
                        for (i = 0; i < json.owner.key_auths.length; i++) {
                            stats.auth[json.owner.key_auths[i][0]] = json.owner.key_auths[i][1]
                            if(agents[keyPairs[json.owner.key_auths[i][0]]] !== undefined){
                                agents[keyPairs[json.owner.key_auths[i][0]]] = {}
                            }
                            agents[keyPairs[json.owner.key_auths[i][0]]].o = json.owner.key_auths[i][1]
                        }
                        stats.auth_ot = json.owner.weight_threshold
                    }
                    //auto update active public keys
                    ops.push({ type: 'put', path: ['stats'], data: stats })
                    ops.push({ type: 'put', path: ['agents'], data: agents })
                    ops.push({ type: 'put', path: ['feed', `${json.block_num}:${json.transaction_id}`], data: 'MultiSig Account Update' })
                            
                    console.log(ops);
                    store.batch(ops, pc)
                } else if (keyPair && json.active !== undefined && json.active.key_auths[0]) {
                    ops.push({ type: 'put', path: ['markets', 'node', json.account, 'pubKey'], data: json.active.key_auths[0][0] }) //keep record of public keys of node operators
                    ops.push({ type: 'put', path: ['feed', `${json.block_num}:${json.transaction_id}`], data: `@${json.account} Account Update` })
                            
                    console.log(ops);
                    store.batch(ops, pc)
                } else {
                    pc[0](pc[2])
                }
            })
            .catch(e => { console.log(e) })
    }

Here you can see we manage both the multi_sig auths but any of the node account key changes as well.

exports.ms_signed = (json, from, active, pc) => {
    if(active && from === config.msaccount){
        store.batch([{type: 'del', path:['ms', 'ops', json.op]}], pc)
    } else {
        pc[0](pc[2])
    }
}

And finally, The final custom_json to delete our msop and maintain a lean state. With this entry gone, the recast won't be called and will also disappear after it's first run.

I'm interested in the thoughts and reactions of any blockchain or dApp devs. As well as anybody interested in running smart contracts or building a community.

I also would love to see my proposal achieve funding to help get this smoothed out and tested. This same system can be used for so many things that currently rely on third party trust.


Edit: I believe I solved the same problem in two ways here... adding to overall complexity.

H2
H3
H4
3 columns
2 columns
1 column
2 Comments