kompact-io-landing/content/posts/principles.md

273 lines
14 KiB
Markdown

---
title: "Principles of dapp design"
date: 2024-11-26
status: published
---
There are a collection of disparate thoughts on what makes for good design that
inform the decisions we make when designing dapps.
We thought it helpful to articulate them for ourselves, for any future
collaborators, and any wider audience interested.
These thoughts are expressed as diktats but are intended as perspective
from which to pick through there shortcomings.
These "principles" don't fit neatly into some SOLID like framework. Some are
high-level general security concious software dev stuff, others are quite
specifically reflecting on a "building an L2 on cardano". Some implications
manifest at the software architecture level, while others inform the development
process.
## Context
Before unpacking the principles, it's worth reflecting on _devving in
Cardanoland_.
The Cardano ledger is a lean by design: it is to provide a kernel of integrity
for larger applications. It was never to, say, host and execute all the
components of an application. It is even a little too lean in places (eg using
key hashes over keys in the script context).
On-chain code is the code executed in the ledger. All integrity guarantees of an
application built over ledger ultimately rely on the on-chain code. On-chain
code is purely discriminatory: show it a transaction and it will succeed or
fail. It cannot generate valid transactions. That is the responsibility for
other, off-chain code. Off-chain code is a necessary part of any such
application, but does not provide integrity guarantees.
We'll call applications, structured such that their integrity is backed by the
ledger, "dapps". Typically the term has other assumptions: a decentralised
application should be run-able by anyone with internet access, reasonable
hardware, and without need to seek permission from some authority.
On-chain code takes form as Plutus validators. Writing validators as
**extreme programming**.
Lets immediately disambiguate with the [XP methedology][xp-wiki].
It's extreme in the sense that it is highly unusual.
[xp-wiki]: https://en.wikipedia.org/wiki/Extreme_programming
It's extreme in the following ways:
- Highly constrained execution environment, which has multiple implications
- Diminishing returns of code re-use
- Conflicting motivation on factorization
- Un-patch-ablity
- High cost of failure
- Functions that do nothing but fail
Resource limitations at runtime are incredibly restrictive. Moreover, all
resources used have to be paid for. This is a concern is shared to some extent
with low level programming but even there is relatively niche on modern
hardware. There are many implications of this. A key one is that implementation
matter.
Libraries have limited use. As noted, the efficiency of an implementation
matters. If one needs to aggregate multiple values over a list of inputs, it is
generally cheaper to do this in a single fold, and without
constructing/deconstructing across a function boundary. Implementation details
cannot be abstracted away in simple one-size-fits-all manner, at least at zero
cost (cf plutus apps and the bloated outputs it would return). In real world
examples this cost is significant enough, that the use of stock library methods
must be considered. One saving grace is that this is manageable. The resource
limitations mean that anything over a couple thousand lines of code risks not
being usable in a transaction anyway.
Factorizing code in way that communicates purpose to a reader should be a strong
consideration. However, we already have another, possibly conflicting,
consideration: implementation efficiency. Some might say this is a compiler
problem, and that we should have clever compilers. To which the most immediate
answer is "yes, but right now we don't". It is also not a full panacea. A clever
compiler is harder to reason about, harder to verify its correctness, and it can
become more obscure to prod the compiler into pathways known to be more
efficient.
Validators are, a priori, not patchable. Depending on the validator, once
'deployed' it may be impossible to update. There is no way to bump or patch a
validator once it is in use, without such functionality being designed in. This
not unique. It was standard in pre-internet software where rolling updates were
infeasible, and still exists for devices that aren't internet enabled. However,
it is now far more the niche than the mainstream.
The correctness of a validator is high stakes. This is a property shared with
any security critical software. It is not the same league as, say, aviation
software, but it is much closer to that than a game or web app. If there is a
bug, then user funds are at risk. This compounds being not patchable. Great
efforts must be spent verifying validator correctness.
Validators are very unusual functions particularly in a functional paradigm.
They take as input a very restricted set of args, and either fail or return
unit. That's all we care about: discriminating acceptable transactions from
unacceptable transactions. Sure, this akin to writing a test, or an assert
condition - but these are commonly auxiliary rather than the culmination of the
code base. Writing Plutus is not akin to, say, some web based API or an ETL
pipeline. There is the potential for the code to be utilized by third parties if
they desired to build their own transactions that involve the validators.
However this use is generally secondary to optimizing for the intended set of
transaction builders.
## Principles
### On-chain code is to keep participants safe from others
On-chain code **is** responsible for keeping the user safe from others. It is
its fundamental responsibility.
On-chain code is **not** responsible for keeping the user safe from themselves.
A user can compromise themselves completely and totally by, say, publishing their signing key,
voiding any guarantees provided by on-chain code.
Thus such guarantees are generally superfluous.
Off-chain code is responsible for keeping the user safe, and it is off-chain
code that should make it hard for them to shoot themselves in the foot.
On-chain code is also not there to facilitate politeness. Good and bad behaviour
is a binary distinction, discriminated thoroughly by a validator. A partner may
stop responding for legitimate or malicious reasons. We do not need to
speculate; we need only ensure that the other partner's funds are safe and can
be recovered.
Suppose there is a dapp that requires the depositing of funds, with a pathway in which the funds
may be returned. Further, that this return is verified by a signature belonging
associated to key provided by the depositor.
Some may wish on-chain code to check everything it is possible to check.
This includes keeping the user safe from themself.
From this perspective the validator should
have a constraint that this key has signed the tx, verifying that the depositor
has the associated signing key.
According to this principle, the validator should not.
The larger code base should.
The documentation should be crystal clear what this key is for and how it should be managed.
But the on-chain code should be solely focused on keeping the user safe from others, and other from the user.
There is a loose sense of the [streetlight effect](https://en.wikipedia.org/wiki/Streetlight_effect) bourne out in code.
### Simplicity invites security
Particularly for on-chain code, err on the side of simplicity. Design and build
such that reasoning around scenarios is straightforward, and answering "what if
..." questions are easy.
This should not be at the expense of being feature complete, although the
principle then becomes a little grey on application. In places it translates to:
develop an abstraction in which the features become simple. In other places, we
will have to find the happy compromise between simple-ness and feature-ful-ness.
For example, in the case of designing [Cardano Lightning](cardano-lightning.org)
can a partner close two of their channels in a single transaction?
This might be important but perhaps could be handled by an abstraction in the application
hiding this detail to the user.
There is overlap here with the previous principle.
By having the on-chain code laser focused, we don't have nice-to-haves,
cluttering the code base, possibly obscuring our view from otherwise glaring bug.
The current principle further justifies excluding logic in the validator
that a self interested participant would be motivated from ensuring themselves.
For example: a validator must check that a thread token never leaves a script address;
it need not check that a participant has paid themselves their due funds.
### Prioritise user autonomy
Deprioritise collaborative actions.
This principle is only employable only in specific circumstances.
In Cardano Lightning a user is responsible for their own funds, and only their
own funds. They cannot spend their partners funds. For example, when winding
down a channel, it requires each user to submit evidence of what they are owed.
The pathway could have enforced that one partner left an address to which the
other partner's tx would output the correct funds. It would potentially save a
transaction in the winding down process. However, it also invites questions of
double satisfaction, and resolutions to this make it harder to reason about.
Instead, following this principle,
the design prioritises multi-channel management.
A fundamental participant type in a healthy Lightning network is the Gateway.
A gateway participant is highly
connected within the network and is (financially) motivated to route payments
between participants. A gateway node in particular needs to manage their
channels, and manage their capital amongst channels as efficiently as possible.
This in the whole network's interest.
Cardano Lightning does permit mutual agreed actions.
However, such actions is considered as a secondary
pathway. Any channel has (at most) the funds of only the two partners of the
channel. Mutual actions are verified by the presence of both partners'
signatures on a transaction. As we assume that any participant will act in a
self interested manner, and is responsible for keeping themselves safe, few
checks are done beyond this.
### Distinguish safety from convenience
Safety comes first, but we also need things to be practical and preferably even
convenient. Make explicit when a feature has been included for safety, or is to
do with convenience.
In some respects, this is restating of the first and or second principles.
The on-chain code is precisely what keeps users safe.
The small distinction is that this extends to off-code too.
There are aspects of off-chain code that are integral, and other parts that are convenient.
### Spec first, implement second
A spec:
- Bridges from intent to code
- Expels ambiguity early
- Says no to feature creep
A spec bridges the design intentions to the implementation. Code is halfway
house between what a human can parse and what a machine can parse. Where that
halfway falls is a question for language design(ers), and there is a full
spectrum of positions available with all the possible languages. Very roughly,
the closer it is to human readable, the less efficient it ends up being executed
by the machine.
Regardless of a language's expressiveness, it doesn't replace the need for
additional documentation.
"Self-describing code" is a nice idea and not without strong merits. Naming
should follow conventions, and should not be knowingly obscure. In software at
large, the problem of separate documentation falling out of sync is observed ad
nauseum. But. The idea that descriptive names and some inline comments are
sufficient to communicate design and intent is not credible. [I agree with
Jef][jef-raskin-blog].
[jef-raskin-blog]: https://queue.acm.org/detail.cfm?id=1053354
The above is especially acute in the context of the extreme programming paradigm
that is Plutus development. We can and should demand more attention from a dev
engaging with the application. They should not expect to "intuitively"
understand how to interface with the code. Again - not to be justify any
obscurity of design or code, but a validator is not simply just another library
they'd be interfacing with. The stakes are too high. They must read the spec.
We suffer less from docs/code divergence than is experienced in "normal" development.
For the same reason that we have un-patch-ablity we have a fixed feature set.
It is in evolving software and its code base, by adding new features or modifying existing ones,
when code diverges from docs.
A spec helps expel ambiguity early. It provides an opportunity to check that
everyone is on the same page before any lines of code are written, and without
having to unpick lines of code after the fact.
A spec helps make the implementation stage straightforward and intentional. It
greatly reduces the required bandwidth since each part of the code has a
prescribed purpose. This is also reduces the cost of writing wrong things,
before settling on something acceptable.
Having a spec combats feature creep. Adding feature requirements part way
through implementation can lead to convoluted and design and code, and
ultimately greatly increase the chance of bugs. As discussed for on-chain code,
rolling updates are not generally possible. We need to make sure from the start
what the feature requirements are (and as importantly what they aren't).
## Summary
The principles have been arrived up over numerous projects,
most explicitly and recently while working on Cardano Lightning.
As alluded to in the introduction, these principles should ...
well, be treated more like [guidelines](https://youtu.be/k9ojK9Q_ARE).
If you comments, questions, suggested, or criticisms, please get in touch.