Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,12 @@ describe('When there is an on-chain DID', () => {
})
})

describe.each(['ed25519', 'sr25519', 'ecdsa'])(
describe.each(['ed25519', 'sr25519', 'ecdsa', 'ethereum'])(
'and an %s account different than the sender to link',
(keyType) => {
// TODO: remove this line to test against ethereum linking enabled chains
const it = keyType === 'ethereum' ? test.skip : test

let keypair: KeyringPair
beforeAll(async () => {
keypair = keyring.addFromMnemonic(
Expand Down
17 changes: 17 additions & 0 deletions packages/did/src/Did.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { u32 } from '@polkadot/types'

import {
DidIdentifier,
JsonEnum,
DidKey,
DidResourceUri,
DidServiceEndpoint,
Expand Down Expand Up @@ -455,3 +456,19 @@ export function assembleKeyUri(
}
return `${did}#${keyId}`
}

/**
* Helper function to simplify creating polkadot's SCALE codec enum types.
*
* @param variant String describing the enum variant. Will be capitalized to yield the key of the returned object's single property.
* @param value Value associated with the variant. Will be the value of the returned object's single property.
* @returns Object in the form { Variant: value }.
*/
export function makeJsonEnum<K extends string, V>(
variant: K,
value: V
): JsonEnum<Capitalize<K>, V> {
return {
[variant.replace(/^[a-z]/g, (s) => s.toUpperCase())]: value,
} as JsonEnum<Capitalize<K>, V>
}
247 changes: 209 additions & 38 deletions packages/did/src/DidLinks/AccountLinks.chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,56 @@

import { BlockchainApiConnection } from '@kiltprotocol/chain-helpers'
import { ss58Format } from '@kiltprotocol/utils'
import {
import type {
Deposit,
DidIdentifier,
IIdentity,
JsonEnum,
SubmittableExtrinsic,
} from '@kiltprotocol/types'

import { encodeAddress, signatureVerify } from '@polkadot/util-crypto'
import type { Option, Struct, u128 } from '@polkadot/types'
import {
decodeAddress,
encodeAddress,
ethereumEncode,
signatureVerify,
} from '@polkadot/util-crypto'
import type {
AccountId,
bool,
Enum,
Option,
Struct,
u128,
u64,
U8aFixed,
} from '@polkadot/types'
import type {
AccountId32,
Extrinsic,
MultiSignature,
} from '@polkadot/types/interfaces'
import type { AnyNumber, TypeDef } from '@polkadot/types/types'
import type { AnyNumber, Codec, TypeDef } from '@polkadot/types/types'
import type { HexString } from '@polkadot/util/types'
import { KeypairType, VerifyResult } from '@polkadot/util-crypto/types'
import { assert, BN, u8aToHex, u8aToU8a, u8aWrapBytes } from '@polkadot/util'
import Keyring from '@polkadot/keyring'
import type { KeypairType, VerifyResult } from '@polkadot/util-crypto/types'
import {
assert,
BN,
stringToU8a,
u8aConcatStrict,
u8aToHex,
u8aToU8a,
u8aWrapBytes,
U8A_WRAP_ETHEREUM,
} from '@polkadot/util'
import type Keyring from '@polkadot/keyring'

import type {
AugmentedQuery,
AugmentedQueryDoubleMap,
AugmentedSubmittable,
} from '@polkadot/api/types'
import { ApiPromise } from '@polkadot/api'
import { makeJsonEnum } from '../Did.utils.js'
import { queryWeb3NameForDidIdentifier, Web3Name } from './Web3Names.chain.js'

// TODO: update with string pattern types once available
Expand All @@ -35,15 +65,12 @@ export type KiltAddress = IIdentity['address']
/// A chain-agnostic address, which can be encoded using any network prefix.
export type SubstrateAddress = IIdentity['address']

export type Address = KiltAddress | SubstrateAddress
export type EthereumAddress = HexString

interface ConnectionRecord extends Struct {
did: AccountId
deposit: Deposit
}
export type Address = KiltAddress | SubstrateAddress | EthereumAddress

/// Type of signatures to link accounts to DIDs.
export type SignatureType = MultiSignature['type']
export type SignatureType = MultiSignature['type'] | 'Ethereum'

/**
* Type of a linking payload signing function.
Expand All @@ -55,8 +82,108 @@ export type LinkingSignerCallback = (
address: KiltAddress
) => Promise<HexString>

interface PalletDidLookupLinkableAccountLinkableAccountId extends Enum {
readonly isAccountId20: boolean
readonly asAccountId20: U8aFixed
readonly isAccountId32: boolean
readonly asAccountId32: AccountId32
readonly type: 'AccountId20' | 'AccountId32'
}

interface PalletDidLookupConnectionRecord extends Struct {
readonly did: AccountId32
readonly deposit: Deposit
}

type LinkableAccountJson = JsonEnum<
PalletDidLookupLinkableAccountLinkableAccountId['type'],
string | Uint8Array
>

type AssociateAccountRequest = JsonEnum<
'Dotsama' | 'Ethereum',
[
string | Uint8Array, // AccountId
string | Uint8Array | JsonEnum<SignatureType, string | Uint8Array> // signature
]
>

type WithEtherumSupport = {
tx: {
didLookup: {
associateAccount: AugmentedSubmittable<
(
req: AssociateAccountRequest,
expiration: u64 | AnyNumber | Uint8Array
) => SubmittableExtrinsic
>
removeAccountAssociation: AugmentedSubmittable<
(account: LinkableAccountJson) => SubmittableExtrinsic,
[PalletDidLookupLinkableAccountLinkableAccountId]
>
}
}
query: {
didLookup: {
connectedDids: AugmentedQuery<
'promise',
(arg: LinkableAccountJson) => Option<PalletDidLookupConnectionRecord>,
[PalletDidLookupLinkableAccountLinkableAccountId]
>
connectedAccounts: AugmentedQueryDoubleMap<
'promise',
(
didId: string | Uint8Array,
accountId: LinkableAccountJson
) => Option<bool>,
[AccountId32, PalletDidLookupLinkableAccountLinkableAccountId]
>
}
}
}

// TODO: improve
function isEthereumEnabled(api: unknown): api is WithEtherumSupport {
return (
api instanceof ApiPromise &&
('isAccountId20' in
api.createType(
api.tx.didLookup.removeAccountAssociation.meta.args[0]?.type?.toString() ||
'bool'
) ||
'isEthereum' in
api.createType(
api.tx.didLookup.associateAccount.meta.args[0]?.type?.toString() ||
'bool'
))
)
}

function encodeMultiAddress(
address: Address
): JsonEnum<'AccountId20' | 'AccountId32', Uint8Array> {
const accountDecoded = decodeAddress(address)
const isEthereumAddress = accountDecoded.length === 20
return makeJsonEnum(
isEthereumAddress ? 'AccountId20' : 'AccountId32',
accountDecoded
)
}

/* ### QUERY ### */

async function queryConnectedDid(
linkedAccount: Address
): Promise<Option<PalletDidLookupConnectionRecord>> {
const api = await BlockchainApiConnection.getConnectionOrConnect()
if (isEthereumEnabled(api)) {
return api.query.didLookup.connectedDids(encodeMultiAddress(linkedAccount))
}
return api.query.didLookup.connectedDids<
Option<PalletDidLookupConnectionRecord>
>(linkedAccount)
}

/**
* Gets deposit information for a given account link.
*
Expand All @@ -66,10 +193,7 @@ export type LinkingSignerCallback = (
export async function queryAccountLinkDepositInfo(
linkedAccount: Address
): Promise<Deposit | null> {
const api = await BlockchainApiConnection.getConnectionOrConnect()
const connectedDid = await api.query.didLookup.connectedDids<
Option<ConnectionRecord>
>(linkedAccount)
const connectedDid = await queryConnectedDid(linkedAccount)
return connectedDid.isSome ? connectedDid.unwrap().deposit : null
}

Expand All @@ -82,13 +206,16 @@ export async function queryAccountLinkDepositInfo(
export async function queryConnectedDidForAccount(
linkedAccount: Address
): Promise<DidIdentifier | null> {
const api = await BlockchainApiConnection.getConnectionOrConnect()
const connectedDid = await api.query.didLookup.connectedDids<
Option<ConnectionRecord>
>(linkedAccount)
const connectedDid = await queryConnectedDid(linkedAccount)
return connectedDid.isNone ? null : connectedDid.unwrap().did.toString()
}

function isLinkableAccountId(
arg: Codec
): arg is PalletDidLookupLinkableAccountLinkableAccountId {
return 'isAccountId32' in arg && 'isAccountId20' in arg
}

/**
* Return all the accounts linked to the provided DID identifier.
*
Expand All @@ -102,11 +229,20 @@ export async function queryConnectedAccountsForDid(
): Promise<Array<KiltAddress | SubstrateAddress>> {
const api = await BlockchainApiConnection.getConnectionOrConnect()
const connectedAccountsRecords =
await api.query.didLookup.connectedAccounts.keys<[AccountId, AccountId]>(
linkedDid
)
return connectedAccountsRecords.map((account) =>
encodeAddress(account.args[1], networkPrefix)
await api.query.didLookup.connectedAccounts.keys(linkedDid)
return connectedAccountsRecords.map<string>(
({ args: [, accountAddress] }) => {
if (isLinkableAccountId(accountAddress)) {
// linked account is substrate address (ethereum-enabled storage version)
if (accountAddress.isAccountId32)
return encodeAddress(accountAddress.asAccountId32, networkPrefix)
// linked account is ethereum address (ethereum-enabled storage version)
if (accountAddress.isAccountId20)
return ethereumEncode(accountAddress.asAccountId20)
}
// linked account is substrate account (legacy storage version)
return encodeAddress(accountAddress.toU8a(), networkPrefix)
}
)
}

Expand Down Expand Up @@ -139,13 +275,19 @@ export async function queryIsConnected(
account: Address
): Promise<boolean> {
const api = await BlockchainApiConnection.getConnectionOrConnect()
// The following function returns something different from 0x00 if there is an entry for the provided key, 0x00 otherwise.
const connectedEntry = await api.query.didLookup.connectedAccounts.hash(
didIdentifier,
account
)
// isEmpty returns true if there is no entry for the given key -> the function should return false.
return !connectedEntry.isEmpty
if (isEthereumEnabled(api)) {
// The following function returns something different than 0x00 if there is an entry for the provided key, 0x00 otherwise.
return !(
await api.query.didLookup.connectedAccounts.hash(
didIdentifier,
encodeMultiAddress(account)
)
).isEmpty
// isEmpty returns true if there is no entry for the given key -> the function should return false.
}
return !(
await api.query.didLookup.connectedAccounts.hash(didIdentifier, account)
).isEmpty
}

/**
Expand Down Expand Up @@ -192,9 +334,23 @@ export async function getAccountSignedAssociationExtrinsic(
sigType: SignatureType
): Promise<Extrinsic> {
const api = await BlockchainApiConnection.getConnectionOrConnect()
return api.tx.didLookup.associateAccount(account, signatureValidUntilBlock, {
[sigType]: signature,
})
if (isEthereumEnabled(api)) {
if (sigType === 'Ethereum') {
return api.tx.didLookup.associateAccount(
{ Ethereum: [account, signature] },
signatureValidUntilBlock
)
}
return api.tx.didLookup.associateAccount(
{ Dotsama: [account, makeJsonEnum(sigType, signature)] },
signatureValidUntilBlock
)
}
return api.tx.didLookup.associateAccount(
account,
signatureValidUntilBlock,
makeJsonEnum(sigType, signature)
)
}

/**
Expand Down Expand Up @@ -233,6 +389,11 @@ export async function getLinkRemovalByDidExtrinsic(
linkedAccount: Address
): Promise<Extrinsic> {
const api = await BlockchainApiConnection.getConnectionOrConnect()
if (isEthereumEnabled(api)) {
return api.tx.didLookup.removeAccountAssociation(
encodeMultiAddress(linkedAccount)
)
}
return api.tx.didLookup.removeAccountAssociation(linkedAccount)
}

Expand All @@ -248,6 +409,8 @@ function getMultiSignatureTypeFromKeypairType(
return 'Sr25519'
case 'ecdsa':
return 'Ecdsa'
case 'ethereum':
return 'Ethereum'
default:
throw new Error(`Unsupported signature algorithm '${keypairType}'`)
}
Expand Down Expand Up @@ -303,7 +466,15 @@ export async function getAuthorizeLinkWithAccountExtrinsic(
validTill,
])
.toU8a()
const paddedDetails = u8aToHex(u8aWrapBytes(encodedDetails))
const paddedDetails = u8aToHex(
decodeAddress(accountAddress).length > 20
? u8aWrapBytes(encodedDetails)
: u8aConcatStrict([
U8A_WRAP_ETHEREUM,
stringToU8a(`${encodedDetails.length}`),
encodedDetails,
])
)
// The signature may be prefixed; so we try to verify the signature without the prefix first.
// If it fails, we try the same with the prefix and return the result of the second operation.
let signature = u8aToU8a(await signingCallback(paddedDetails, accountAddress))
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/DidDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,7 @@ export type DidSignature = {
keyUri: DidResourceUri
signature: string
}

export type JsonEnum<K extends string, V> = K extends any
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks just like Record. What does it do differently and is it important enough to have a new type for it? Because it makes things more confusing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I played around with a bit and Record would not work here.
The only solution I could come up with is:

export type JsonEnum<K extends string, V> = {
  [P in K]?: V
}

Which I like more, because it is more straightforward and understandable. Only downside is, that it allows empty objects, but I don't see a way around that. On the other side,makeJsonEnum would not be needed anymore, which currently uses type assertion with as and is needed, because it would not be possible to assign to the JsonEnum type without it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK it's the only way to turn 'a' | 'b' into something like {a: any} | {b: any}, which is the sort of type that polkadot requires for the creation of enums. What you proposed gives you {a?: any, b?: any} which besides empty objects also allows {a: 1, b: 2}. And it is not assignable to the polkadot type, so I don't think that will work.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another way is to list the types explicitly: {a: any} | {b: any}. It is wordier and longer (a downside) but also explicit (an upside).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but what does that solve? if variable a is typed as a union of string literals, typescript interprets {[a]: any} as {[key: string]: any} and will not accept it for a signature that requires {a: any} | {b: any}

? { [P in K]: V }
: never