capi
capi copied to clipboard
Random bytes as `MultisigRune` signatories
May want to consider narrowing the signatories type here
Can you please be more specific about the desired narrowing? Do you mean a narrower type for signatories or narrowing the resulting MultisigRune to represent the supplied signatories?
In the former case, it's a bit tricky bc the Uint8Array type doesn't carry length information as do tuples. I suppose you could intersect Uint8Array with { length: 32 }... but this would hinder productivity in cases where signatories are retrieved dynamically. The length would effectively serve as a brand, and users would need to create helpers to apply the brand.
Ideally the MultisigRune would take an array of strings representing member addresses, decode them automatically and make sure they're valid.
Although string[] is not that narrow, if's a bit more safe than Uint8Array[] for UI use cases.
Apart from this, it would remove a manual step in the rune creation.
Instead of this
const addresses = [
"5CfJAuXp1SgJ5Fb1AhSzQk61nFXtvzs4GRuQpouBURaP3zXk",
"5CAmWhTjv6c6ab2uJHazXBiRMSeHy8XHkQMRXLdEeeMTEfw7",
"5HpR2ELVTHADFAfXxZY8jJjZQMDhjJwMB6ZcHCHJm2yk6C1G",
"5EAYdWBh4SAontqNxPkgwegFNHKQnC8TsqJ1ZXJ4Ku58aBCe",
]
// signatories is Uint8Array[]
const signatories = addresses.map((address) => {
const [_, addressBytes] = ss58.decode(address)
return addressBytes
})
const multisig = MultisigRune.from(westend, { signatories, threshold: 2 })
we could do this
const signatories = [
"5CfJAuXp1SgJ5Fb1AhSzQk61nFXtvzs4GRuQpouBURaP3zXk",
"5CAmWhTjv6c6ab2uJHazXBiRMSeHy8XHkQMRXLdEeeMTEfw7",
"5HpR2ELVTHADFAfXxZY8jJjZQMDhjJwMB6ZcHCHJm2yk6C1G",
"5EAYdWBh4SAontqNxPkgwegFNHKQnC8TsqJ1ZXJ4Ku58aBCe",
]
const multisig = MultisigRune.from(westend, { signatories, threshold: 2 })
Ideally the
MultisigRunewould take an array of strings representing member addresses, decode them automatically and make sure they're valid. Althoughstring[]is not that narrow, if's a bit more safe thanUint8Array[]for UI use cases.Apart from this, it would remove a manual step in the rune creation.
Instead of this
const addresses = [ "5CfJAuXp1SgJ5Fb1AhSzQk61nFXtvzs4GRuQpouBURaP3zXk", "5CAmWhTjv6c6ab2uJHazXBiRMSeHy8XHkQMRXLdEeeMTEfw7", "5HpR2ELVTHADFAfXxZY8jJjZQMDhjJwMB6ZcHCHJm2yk6C1G", "5EAYdWBh4SAontqNxPkgwegFNHKQnC8TsqJ1ZXJ4Ku58aBCe", ] // signatories is Uint8Array[] const signatories = addresses.map((address) => { const [_, addressBytes] = ss58.decode(address) return addressBytes }) const multisig = MultisigRune.from(westend, { signatories, threshold: 2 })we could do this
const signatories = [ "5CfJAuXp1SgJ5Fb1AhSzQk61nFXtvzs4GRuQpouBURaP3zXk", "5CAmWhTjv6c6ab2uJHazXBiRMSeHy8XHkQMRXLdEeeMTEfw7", "5HpR2ELVTHADFAfXxZY8jJjZQMDhjJwMB6ZcHCHJm2yk6C1G", "5EAYdWBh4SAontqNxPkgwegFNHKQnC8TsqJ1ZXJ4Ku58aBCe", ] const multisig = MultisigRune.from(westend, { signatories, threshold: 2 })
The same argument could be made if you had a list of multiaddress signatories and would now need to convert them into string. I think we can support both input types but it would require a discriminator as I don't think there is a reasonable way to determine if an array is string[] or Uint8Array[] at runtime.
const signatories = {
type: "ss58Encoded",
members: [
"5CfJAuXp1SgJ5Fb1AhSzQk61nFXtvzs4GRuQpouBURaP3zXk",
"5CAmWhTjv6c6ab2uJHazXBiRMSeHy8XHkQMRXLdEeeMTEfw7",
"5HpR2ELVTHADFAfXxZY8jJjZQMDhjJwMB6ZcHCHJm2yk6C1G",
"5EAYdWBh4SAontqNxPkgwegFNHKQnC8TsqJ1ZXJ4Ku58aBCe",
]
}
MultisigRune.from(westend, { signatories, threshold: 2 })
...
const signatories = {
type: "ss58Decoded",
members: [
new Uint8Array(),
new Uint8Array(),
new Uint8Array(),
new Uint8Array()
]
}
MultisigRune.from(westend, { signatories, threshold: 2 })
I don't think we should get in the habit of overloading rune factories. Account IDs are more commonly used on chain for things such as keying into user-specific map entries / specifying users in call data. Account IDs are a lower-level representation than SS58. I understand that account IDs / Uint8Arrays aren't as easy to render in a UI / input in a text field, but I don't think that merits moving further from the FRAME-metadata-based translations.
That being said, I think you can smooth this out with a reusable helper.
ss58_util.ts
import { ss58 } from "capi"
export function publicKeys(addrs: string[]) {
return addrs.map((a) => ss58.decode(a)[1])
}
Which you could use as follows.
import { publicKeys } from "./ss58_util.ts"
const signatories = [
"5CfJAuXp1SgJ5Fb1AhSzQk61nFXtvzs4GRuQpouBURaP3zXk",
"5CAmWhTjv6c6ab2uJHazXBiRMSeHy8XHkQMRXLdEeeMTEfw7",
"5HpR2ELVTHADFAfXxZY8jJjZQMDhjJwMB6ZcHCHJm2yk6C1G",
"5EAYdWBh4SAontqNxPkgwegFNHKQnC8TsqJ1ZXJ4Ku58aBCe",
]
const multisig = MultisigRune.from(westend, {
signatories: publicKeys(signatories),
threshold: 2,
})
In the case that signatories is a rune of string, you could do the following.
import { ss58, RunicArgs } from "capi"
export function publicKeys<X>(...[addrs]: RunicArgs<X, [string[]]>) {
return Rune
.resolve(addrs)
.map((addrs) => addrs.map((addr) => ss58.decode(addr)[1]))
}
This approach allows you to use the publicKeys util with either a list of addresses or a rune of a list of addresses.
I guess i'm trying to make 2 arguments, one that string[] is more descriptive and readable if not safer.
Second argument is more about development experience. During app development we can get these addresses from different places like a form or a wallet, but we usually if not always work with SS58 encoded strings.
A nice development experience would be to have the Capi handle the decoding, at least at the pattern level.
We have created helpers around this of course, but just having to call them each time we need something from Capi signals that it would be perhaps better if Capi could accept string addresses.
I could see a world in which we have another static method / factory of MultisigRune that accepts the addresses for signatories.
- const multisig = MultisigRune.from(westend, { signatories: publicKeys, threshold: 2 })
+ const multisig = MultisigRune.fromSs58(westend, { signatories: ss58Addresses, threshold: 2 })
I just wonder where we'll draw the line? Having many factories, each with subtly-different props, is a slippery slope. The from methods should really just accept the T, that is the data on which the pattern code operates; the raw calls and storage reads operate with account IDs, not ss58s.
If you feel very strongly about this, I'll go ahead and add the fromSs58 factory.
The from methods should really just accept the T, that is the data on which the pattern code operates; the raw calls and storage reads operate with account IDs, not ss58s.
Indeed, the raw calls operate with binary data. However, the whole beauty of a user-friendly API is to simplify the developer's interaction with it.
In my opinion, we shouldn't need to worry about the kind of data these raw calls are working with - that's the whole point of having a user-friendly API in the first place! A robust API should effectively abstract these complexities.
I think this discussion has drifted from the central issue, so I'm posting to restart the discussion.
I think this issue is part of a broader question of address types in our API. @statictype I know I discussed this some with you at Decoded, but I think the conversation got disrupted by everything else going on, so I'd like to start from scratch.
I see there being three main address types under discussion:
Ss58MultiAddressAccountId(aka a public key)
Ideally, these would all be interchangeable. Unfortunately, there are many directions we can't convert.
Here's a diagram of how we can convert these address types:
Ss58
/ \
v v
AccountId ---> MultiAddress
(we can't convert AccountId to Ss58 without the chain prefix, and though we can convert a MultiAddress.Id to an AccountId, we can't convert the other types of MultiAddress)
Which type(s) do we accept in our API?
There are parts of Capi's API that we don't directly control – those generated from the chain metadata. Take, for example, Balances.transfer. In the metadata, dest is typed as MultiAddress, and we can't change that[^1].
[^1]: Though we do change the shape of some codecs from the metadata, we can only do this when there is a bidirectional conversion between the two types. For example, we can change Codec<{ foo_bar: Baz }> to Codec<{ fooBar: Baz }> because the conversion between those types can go both ways. Unfortunately, since we can't convert a MultiAddress to an Ss58, we can't change Codec<MultiAddress> to Codec<Ss58>.
Thus, some of Capi's API must accept MultiAddress.
However, as @statictype points out, when we develop patterns, we do have the ability to make a more user-friendly API.
Here are a few different options in two different cases:
Case 1: The chain wants a MultiAddress
Option 1a: Our API wants a MultiAddress, too
This is, of course, the easiest option, but that shouldn't be a factor – what matters is the end user experience.
This does have the advantage that it's consistent with Capi's generated APIs.
However, as @statictype has pointed out, one drawback of this approach is that frontends usually deal with Ss58s, so app devs would have to manually convert.
Option 1b: Our API wants an Ss58 instead
This resolves the issue of having to manually convert an Ss58 to a MultiAddress.
However, if the user already has a MultiAddress, they can't use this API, as a MultiAddress can't always be converted to an Ss58.
Option 1c: Our API wants a MultiAddress | Ss58
In one sense, this does simplify the end user experience.
However, we (the Capi team) have found in general that APIs with many different overloaded input types with automatic conversion are harder to use overall, as they obscure the real logic and overwhelm the consumer with many (often irrelevant) options.
Thus, the Capi team has adopted a policy: the Capi API won't use union types with automatic conversion.
Option 1d: We have two methods, one which takes MultiAddress, and one which takes Ss58
I think this is a reasonable middle ground. It doesn't prevent the use of MultiAddress, it makes the experience better when one has an Ss58, and it doesn't introduce a auto-converted union type.
However, as @harrysolovay pointed out, this kind of approach can start to snowball, so we need to consider it carefully.
Case 2: The chain wants an AccountId
The four options in Case 1 have analogues here:
- Option 2a: Our API wants an
AccountIdas well - Option 2b: Our API wants an
Ss58instead - Option 2c: Our API wants an
AccountId | Ss58 - Option 2d: We have two methods, one which takes
AccountId, and one which takesSs58
But I also can think of two other notable options:
Option 2e: Our API wants a MultiAddress.Id
I think this is an interesting variant.
- If you have an
AccountId, you can quickly convert it into aMultiAddress.Idwith a single call - If you have a
MultiAddress, you can either:- Do an explicit check to see if it's an
Id, and if so pass it through - If you know it must be an
Id, useas MultiAddress.Idand pass it through- On the implementation side, we would have a sanity check that ensures it's an
Id, to make this option safer.
- On the implementation side, we would have a sanity check that ensures it's an
- Do an explicit check to see if it's an
If you have an Ss58, this is pretty similar to Option 2a (namely, less ergonomic).
Option 2f: We have two methods, one which takes MultiAddress.Id, and one which takes Ss58
This is a combination of 2d and 2e.
So, what options do we choose?
My current thoughts are:
-
For
MultisigRune, go with 2f – namely, haveMultisigRune.fromandMultisigRune.fromSs58factories. Seeing as this is a rare case of an array of addresses, converting isn't a single function call – you have to map over the array, which is more verbose and error-prone, so providing an option to skip this explicit step seems worthwhile. -
In other parts of the API, go with 1a or 2e (as appropriate). Though I see the appeal of 1d and 2d/2f, I think these alternate versions could get out of hand and ultimately make the API harder to use. I think we should instead ease the pain of converting these addresses in a more general way. So:
How can Capi resolve these pain points more generally?
ss58.toAccountId
I think one easy & high-value option with general utility would be for Capi to add an ss58.toAccountId function. Currently, you have to write ss58.decode(mySs58)[1], which is ugly and error-prone. Since in many cases, you don't care about the chain prefix, you shouldn't have to explicitly discard it.
Other options
Disclaimer: I'm not working on an app using Capi, so my perspective on the app dev experience is incomplete. However, I think this proposal may reduce some pain points:
Use MultiAddress.Ids as the main form of addresses in apps
When developing Capi-based apps, use MultiAddress.Ids as the main form of address in the logic layer, and render/input Ss58s in the presentation layer.
This means:
- any raw chain APIs that accept a
MultiAddresscan be used directly - any raw chain APIs that accept an
AccountIdcan be used with a simple property access (.value) - Capi patterns can take
MultiAddress.Id/MultiAddressas appropriate, and both can be used directly- This allows Capi patterns to be used like the raw chain APIs
@statictype Thoughts?
Thank you for the detailed explanation but I still feel this is not a user concern. Please refer to the way this is handled in "competing" libraries (pjs, ethers.js, web3.js) who seem to allow string params throughout their API. The assumption that the types of the parameters these Runes accept need to comply with the ones used by the underlying logic layer is creating unnecessary friction for the end user.
Here is a simple illustration:
capi
local.Nfts.create({
config: collectionConfig,
admin: MultiAddress.Id(ss58.decode(activeAccount.address)[1]),
})
pjs
api.tx.nfts.create(activeAccount.address, collectionConfig)
Here is a quote from docs:
It consists of a development server and fluent API, which facilitates multichain interactions without compromising either performance or ease of use.
From a dev perspective capi does seem to compromise the ease of use. Ideally I shouldn't care what happens inside of the API and let the API devs decide on how to make my experience as frictionless as possible. If it's actually very problematic or in some cases impossible to do conversions on the fly inside of capi and I need to do this myself, then a solution to this would be a comprehensive documentation with examples on what exactly capi wants from me and how can I provide that.