SCF Voting Mechanism implementation
Introduction
Neural Quorum Governance (NQG) is a novel governance and voting mechanism designed by R&D firm BlockScience in collaboration with the Stellar Development Foundation (SDF) to power award allocation of the Stellar Community Fund (SCF), an open-application awards program managed by the SDF that draws on community input to support projects building on the Stellar network. NQG can provide the means to solve certain common issues most voting mechanisms experience including the inaccurate sentiment of the larger community, voter fatigue, and apathy, as well as a lack of growth/change in the core group of voters.
With the support of the SDF and BlockScience teams, we built the very first implementation of NQG using Soroban — Stellar’s native Rust-based smart contracts platform (currently available on Testnet). In this article, I will cover the following topics:
- Brief overview of the design of the system,
- Implementation on Soroban,
- Integrating Soroban with Typescript.
System Design
Inspired by the use of quorums and trust in the Stellar Consensus Protocol, Neural Quorum Governance combines two key features:
- Neural Governance, which enables modular, plug-and-play development and exploration of mechanisms for voting-power attribution,
- Quorum Delegation, which is a new delegation scheme that allows users to delegate their votes without having to trust a single delegate.
To further understand how the system works, we can look at it from both the voter and admin perspective.
The voter perspective
For the voters, the system is relatively simple. There are some projects and every voter can perform one of the following actions:
- Vote yes — when they think a project should get funding.
- Vote no — when they think a project shouldn’t get funding
- Abstain — when they don’t want to cast a vote for a project
- Delegate — when they want to vote but do not want to decide themselves.
The admin perspective
For the admin, there are a lot of possibilities on how the system may be customized. The votes themselves remain the same but admins are able to change the way how the votes are evaluated.
Neural Governance
The main component of the system is called Neural Governance, which determines a voter’s Voting Weight by layering and aggregating multiple Voting Neurons (composed of Oracle and Weighting functions).
Neural Governance contains any number of Layers (at least one), and every Layer may have any number of Neurons (as well as at least one is required for every Layer). Every Neuron has a specific logic of how to calculate the Weight of the vote. The Neurons inside of a Layer are executed separately and the order does not matter. Once all the Neurons are processed, the Layer result is calculated using a Layer Aggregator, which is set for every Layer.
It may add all the Neurons’ results, multiply them, or do any other operation that just takes a sequence of results and outputs one single number which is the Layer’s result.
All the Layers are executed sequentially, the order matters in this case as the result of one Layer affects how the Neurons in the next one are evaluated. The result of the last calculated Layer is treated as a result of the Neural Governance.
Neural Governance is executed for every vote calculating its weight. At the end of every voting round, the votes’ weights are tallied resulting in a voting power for every project.
Neurons implemented for the MVP
For the initial implementation, we built three relatively simple Neurons:
- Trust Bonus Neuron — users that have several other users' trust assigned to them will have a higher Voting Weight.
- Reputation Badge Neuron — users that have a higher reputation in the community will have a higher Voting Weight.
- Voting History Neuron — the more one user voted in the past the higher the Voting Weight.
For example, let’s say we have a voter who earned NFT badges in Soroban Quest by completing coding challenges, has trust assigned from several community members, and participated in multiple voting rounds already, and we also have two newcomers. The Voting Weight of this one “experienced” user will be higher than the two newcomers’ votes.
As the system is designed to be flexible and modular, it’s open to receiving any type of Neurons with custom logic, and plugging in a new Neuron is relatively easy.
Quorum Delegation
Aside from voting for projects themselves, Quorum Delegation allows a qualified member to select other voters as delegates. For the first implementation, a member can choose any number from 5 to 10 members they want to delegate their votes to. This member’s vote will be then determined based on the majority vote of all selected delegates.
For example, if a member selects 7 delegates from the community and 4 of them voted “yes”, then this member’s vote is going to be “yes” as well. If there is the same amount of “yes” and “no” votes or there are not enough votes to decide, the member will simply abstain.
If you’re interested in learning more about the design of Neural Quorum Governance, check out this insightful blog post by BlockScience.
Implementation on Soroban
As part of the collaboration with SDF, BlockScience provided a Proof of Concept of NQG’s design, which we used to build the frontend and back-end app, as well as the Soroban smart contracts involved.
The idea was to have the frontend app call the backend which then would be calling the smart contracts, change and read their state. We didn’t want to call the smart contracts directly from the front end because of the additional logic and security provided by the backend side.
As we started building out the architecture, we had to pivot our approach to the setup of smart contracts.
Approach 1: Let’s make everything a contract (not good ⛔)
At first, there was an idea of making every component of the system a smart contract. In such a case, we would have a separate contract instance for:
- Neural Governance
- every Layer
- every Neuron
- External Data Provider (more about this component later)
In this architecture, smart contracts would have a reference to each other using the contracts’ addresses. So, to make it work, we would have to first deploy all the contracts and then set up the references after that. This solution, even though being highly modular, turned out to be very inefficient, therefore we decided to decrease the number of contracts in the system as much as we could (down to two of them).
Approach 2: As few contracts as possible (Good! 🎉)
Multi-contract calls are generally expensive in smart contract platforms, so it was crucial to build the implementation as efficiently as possible In this architecture, there are only two smart contracts:
- Voting System:
This contains all the API of the system, so any interactions from the users (and part of them from the admins) go through this contract. Also, the whole structure of the Neural Governance (Layers, Neurons) is packed up in it. The users can vote using this contract and the admins can manipulate the structure of the Neural Governance — add and remove Neurons and Layers, set the Layers’ aggregators, etc. It has only one reference to a different smart contract — External Data Provider.
- External Data Provider
This contract is responsible for storing any data that comes from outside of the system. This could be information on the voting history of users or their reputations. Only admins should be able to read and modify the storage of this contract and the Voting System should be able only to read it.
Encountered problems
- Decimal numbers — a really big issue for us was that Soroban does not support decimal point numbers so we had to write our own implementation for this.
- Error handling — the error stack traces are really messy and often not really helpful, this gets even worse when smart contracts are used from the outside. When we called Soroban contracts from typescript we had to basically guess what might’ve gone wrong because the error messages would give us nothing useful.
- Converting strings and numbers — as far as I’m concerned it is not possible at the moment. I think this feature is simple enough, it may not be that crucial but it happens to be a pain. For me, it was problematic when I implemented the decimal numbers handler from the first point.
- Dead transactions — we noticed that sometimes the transactions would just die for no good reason. What I mean by “die” is hang forever when sent. This would happen randomly and you’d never know when it may occur. As a workaround, we just implemented a mechanism of retrying but I realize it is a dirty one. I think this is a problem with the blockchain/network itself.
- Dead transactions part 2 — We noticed a similar behavior when the limits are exceeded — the transactions would hang forever. Especially when there are a lot of calculations, the most fragile is the CPU instructions limit. I think it would be better if there was a specific error thrown saying that one of the limits has been reached (ideally which one).
- Map problem — When passing a map from JavaScript to Soroban, there were two problems. First, we had to “sort” the map. This means when converting to ScValue, we had to insert the items there in a certain order (basically sorted the keys alphabetically). But this did not suffice. It turned out that specific items caused this operation to fail. We could not find a pattern for this, unfortunately.
- Conversions between XDR and human-readable data — Let’s say we have a contract with a function that returns a value and we want to invoke it from typescript and use the result in the code. In order to extract and convert the returned value, we have to do something like this:
let resultMetaXdr = response.resultMetaXdr.toXDR();
let resultMetaXdrString = resultMetaXdr.toString("base64");
let meta = xdr.TransactionMeta.fromXDR(
resultMetaXdrString,
"base64"
)
let sorobanMeta = meta.v3().sorobanMeta();
if (sorobanMeta === null) {
throw new Error("sorobanMeta is null");
}
let rawResponse = sorobanMeta.returnValue();
let result = SorobanClient.scValToNative(rawResponse);
I think this is not super handy, I think it also changed a bit with one of the releases, so really it would be great to have a simple function doing all this already provided by the SDK.
Integrating Soroban with Typescript
The goal here was to be able to call the contract from the typescript code so we could integrate it with our web application. There are two options that have been considered:
- use the typescript bindings
- invoke the contracts manually using soroban-client
The former one is certainly more handy and should be preferred. The problem is that the bindings are strictly designed only to be used from the web browser. Inside, they use the Freighter Wallet to access the account’s keys. The keys are necessary for example for signing transactions. As I mentioned before, we needed to invoke smart contracts from the backend app and we could not find a way of plugging in a wallet instance there. So, we turned to the latter possibility.
Invoking the contracts manually did not work at first as we were using Cloudfare/Wrangler for the backend, which seemed not to use node.js. As we moved to Vercel, we finally managed to get this working.
Conclusion
I really liked developing in Soroban, I think the approach of using Rust with the#![no_std] option is really clever as Rust is just a great language and the development process is really smooth. The Soroban CLI is a very good tool too.
I really liked the documentation of Soroban, sometimes it’s a bit outdated but I only saw that when I dug deep into the details. Also, the community of Soroban is very helpful, I totally recommend the discord channels if you’re stuck.
With all that said, there’s room to improve I think before Soroban launches on mainnet. I mentioned some problems we had above, some of them can be easily fixed and others are more complex. If it was up to me, I would prioritize improving error handling, as this has a huge impact on the development process.
In this article, I pointed out some problems, but it is possible I’ve missed something and it may also happen that they will be solved in the future as Soroban is still in development. If you think that something I mentioned is actually doable, feel free to leave a comment!
Thank you for reading this, I hope this was helpful or at least interesting :)
Special thanks to Mateusz Kowalski and Alejo Mendoza for help in developing the system!