Skip to content

Commit f1e6b7e

Browse files
authored
feat: custom runtime functions to generate deterministic Eth address from an MSA ID (#2358)
# Goal This PR adds some internal pallet functions and custom runtime to deterministically generate an Ethereum address from an MSA ID, and vice versa. The general approach is: - Use a domain separator prefix to eliminate any possible conflict with addresses generated by `CREATE2` - Use a salt that is the hash of the string "MSA Generated" - Take the last 20 bytes of the hash of `(prefix + MSA ID + salt)` - If returning a string for the runtime query, pass through an [ERC-55 checksum algorithm](https://github.com/ethereum/ercs/blob/master/ERCS/erc-55.md) to alter the returned hex string Note, although addresses generated in this manner cannot be reversed to recover the related MSA ID, they can be verified (ie, given an address and an MSA ID, we can determine whether or not the address is the result of applying the above algorithm to the MSA ID). It is not envisioned that there would ever be an _on-chain_ requirement to recover the MSA ID from the address; if such a use case was desired for off-chain purposes, it would be relatively trivial to populate a static lookup table mapping all known or possible MSAs to their corresponding address, or vice-versa. Closes #2353 Closes #2352 # Discussion - Q0: Currently returning ASCII bytes; or do we want to return the binary address bytes? - A: Now returning a struct containing both the binary address & checksummed string # Checklist - [x] Updated Pallet Readme? - [ ] Updated js/api-augment for Custom RPC APIs? - [ ] Design doc(s) updated? - [x] Unit Tests added? - [x] e2e Tests added? - [ ] Benchmarks added? - [x] Spec version incremented?
1 parent 3102201 commit f1e6b7e

File tree

14 files changed

+837
-488
lines changed

14 files changed

+837
-488
lines changed

Cargo.lock

Lines changed: 480 additions & 469 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ thiserror = "2.0.12"
2222
apache-avro = { version = "0.17.0", default-features = false }
2323
rand = "0.9.0"
2424
parking_lot = "0.12.1"
25+
lazy_static = { version = "1.5", features = ["spin_no_std"] }
2526

2627
# substrate wasm
2728
parity-scale-codec = { version = "3.6.12", default-features = false }

common/primitives/src/msa.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ pub use crate::schema::SchemaId;
1515
/// Message Source Id or msaId is the unique identifier for Message Source Accounts
1616
pub type MessageSourceId = u64;
1717

18+
/// Ethereum address type alias
19+
pub use sp_core::H160;
20+
21+
/// Response type for getting Ethereum address as a 20-byte array and checksummed hex string
22+
#[derive(TypeInfo, Encode, Decode)]
23+
pub struct AccountId20Response {
24+
/// Ethereum address as a 20-byte array
25+
pub account_id: H160,
26+
27+
/// Ethereum address as a checksummed 42-byte hex string (including 0x prefix)
28+
pub account_id_checksummed: alloc::string::String,
29+
}
30+
1831
/// A DelegatorId an MSA Id serving the role of a Delegator.
1932
/// Delegators delegate to Providers.
2033
/// Encodes and Decodes as just a `u64`

deny.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ ignore = [
7878
{ id = "RUSTSEC-2025-0010", reason = "Unmaintained sub-dependency: ring, pending updates to those dependencies."},
7979
{ id = "RUSTSEC-2025-0009", reason = "Unmaintained sub-dependency: ring, pending updates to dependencies."},
8080
{ id = "RUSTSEC-2024-0436", reason = "Unmaintained sub-dependency: paste, pending updates to dependencies."},
81-
{ id = "RUSTSEC-2025-0017", reason = "Renamed sub-dependency: trust-dns-proto -> hickory-proto, pending updates to dependencies."}
81+
{ id = "RUSTSEC-2025-0017", reason = "Renamed sub-dependency: trust-dns-proto -> hickory-proto, pending updates to dependencies."},
82+
{ id = "RUSTSEC-2023-0091", reason = "'Users prior to 10.0.0 are unaffected', according to the advisory; we are on 8.0.1"},
83+
{ id = "RUSTSEC-2024-0438", reason = "Windows only"}
8284
]
8385
# If this is true, then cargo deny will use the git executable to fetch advisory database.
8486
# If this is false, then it uses a built-in git library.
@@ -107,6 +109,7 @@ allow = [
107109
"OpenSSL",
108110
"Zlib",
109111
"Unicode-3.0",
112+
"CDLA-Permissive-2.0"
110113
]
111114
# The confidence threshold for detecting a license from license text.
112115
# The higher the value, the more closely the license text must be to the

e2e/miscellaneous/balance.ethereum.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ describe('Balance transfer ethereum', function () {
1919
before(async function () {
2020
senderSr25519Keys = await createAndFundKeypair(fundingSource, 30n * DOLLARS);
2121
senderEthereumKeys = await createAndFundKeypair(fundingSource, 30n * DOLLARS, undefined, undefined, 'ethereum');
22-
ethereumKeys = await createKeys('another-key-1', 'ethereum');
23-
ethereumKeys2 = await createKeys('another-key-2', 'ethereum');
24-
sr25519Keys = await createKeys('another-sr25519', 'sr25519');
22+
ethereumKeys = createKeys('another-key-1', 'ethereum');
23+
ethereumKeys2 = createKeys('another-key-2', 'ethereum');
24+
sr25519Keys = createKeys('another-sr25519', 'sr25519');
2525
});
2626

2727
it('should transfer from sr25519 to ethereum style key', async function () {

e2e/msa/msaTokens.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/* eslint-disable mocha/no-skipped-tests */
2+
import '@frequency-chain/api-augment';
3+
import assert from 'assert';
4+
import { ExtrinsicHelper } from '../scaffolding/extrinsicHelpers';
5+
import { ethereumAddressToKeyringPair } from '../scaffolding/ethereum';
6+
import { getFundingSource } from '../scaffolding/funding';
7+
import { H160 } from '@polkadot/types/interfaces';
8+
import { bnToU8a, hexToU8a, stringToU8a } from '@polkadot/util';
9+
import { KeyringPair } from '@polkadot/keyring/types';
10+
import { keccak256AsU8a } from '@polkadot/util-crypto';
11+
import { getExistentialDeposit } from '../scaffolding/helpers';
12+
13+
const fundingSource = getFundingSource(import.meta.url);
14+
15+
/**
16+
*
17+
* @param msaId
18+
* @returns Ethereum address generated from the MSA ID
19+
*
20+
* This function generates an Ethereum address based on the provided MSA ID,
21+
* using a specific hashing algorithm and a salt value, as follows:
22+
*
23+
* Domain prefix: 0xD9
24+
* MSA ID: Big-endian bytes representation of the 64-bit MSA ID
25+
* Salt: Keccak256 hash of the string "MSA Generated"
26+
*
27+
* Hash = keccak256(0xD9 || MSA ID bytes || Salt)
28+
*
29+
* Address = Hash[-20:]
30+
*/
31+
function generateMsaAddress(msaId: string | number | bigint): H160 {
32+
const msa64 = ExtrinsicHelper.api.registry.createType('u64', msaId);
33+
const msaBytes = bnToU8a(msa64.toBn(), { isLe: false, bitLength: 64 });
34+
const salt = keccak256AsU8a(stringToU8a('MSA Generated'));
35+
const combined = new Uint8Array([0xd9, ...msaBytes, ...salt]);
36+
const hash = keccak256AsU8a(combined);
37+
38+
return ExtrinsicHelper.api.registry.createType('H160', hash.slice(-20));
39+
}
40+
41+
describe('MSAs Holding Tokens', function () {
42+
const MSA_ID_1234 = 1234; // MSA ID for testing
43+
const CHECKSUMMED_ETH_ADDR_1234 = '0x65928b9a88Db189Eea76F72d86128Af834d64c32'; // Checksummed Ethereum address for MSA ID 1234
44+
let ethKeys: KeyringPair;
45+
let ethAddress20: H160;
46+
47+
before(async function () {
48+
ethAddress20 = ExtrinsicHelper.apiPromise.createType('H160', hexToU8a(CHECKSUMMED_ETH_ADDR_1234));
49+
ethKeys = ethereumAddressToKeyringPair(ethAddress20);
50+
});
51+
52+
describe('getEthereumAddressForMsaId', function () {
53+
it('should return the correct address for a given MSA ID', async function () {
54+
const expectedAddress = CHECKSUMMED_ETH_ADDR_1234.toLowerCase();
55+
const { accountId, accountIdChecksummed } =
56+
await ExtrinsicHelper.apiPromise.call.msaRuntimeApi.getEthereumAddressForMsaId(MSA_ID_1234);
57+
assert.equal(accountId.toHex(), expectedAddress, `Expected address ${expectedAddress}, but got ${accountId}`);
58+
assert.equal(
59+
accountIdChecksummed.toString(),
60+
CHECKSUMMED_ETH_ADDR_1234,
61+
`Expected checksummed address ${CHECKSUMMED_ETH_ADDR_1234}, but got ${accountIdChecksummed.toString()}`
62+
);
63+
});
64+
65+
it('should validate the Ethereum address for an MSA ID', async function () {
66+
const isValid = await ExtrinsicHelper.apiPromise.call.msaRuntimeApi.validateEthAddressForMsa(
67+
generateMsaAddress(MSA_ID_1234),
68+
MSA_ID_1234
69+
);
70+
assert.equal(isValid, true, 'Expected the Ethereum address to be valid for the given MSA ID');
71+
});
72+
73+
it('should fail to validate the Ethereum address for an incorrect MSA ID', async function () {
74+
const isValid = await ExtrinsicHelper.apiPromise.call.msaRuntimeApi.validateEthAddressForMsa(
75+
CHECKSUMMED_ETH_ADDR_1234,
76+
4321
77+
);
78+
assert.equal(isValid, false, 'Expected the Ethereum address to be invalid for a different MSA ID');
79+
});
80+
});
81+
82+
describe('Send tokens to MSA', function () {
83+
it('should send tokens to the MSA', async function () {
84+
const ed = await getExistentialDeposit();
85+
const transferAmount = 1n + ed;
86+
let accountData = await ExtrinsicHelper.getAccountInfo(ethKeys);
87+
const initialBalance = accountData.data.free.toBigInt();
88+
const op = ExtrinsicHelper.transferFunds(
89+
fundingSource,
90+
ethereumAddressToKeyringPair(ethAddress20),
91+
transferAmount
92+
);
93+
94+
const { target: transferEvent } = await op.fundAndSend(fundingSource);
95+
assert.notEqual(transferEvent, undefined, 'should have transferred tokens');
96+
97+
accountData = await ExtrinsicHelper.getAccountInfo(ethKeys);
98+
const finalBalance = accountData.data.free.toBigInt();
99+
assert.equal(
100+
finalBalance,
101+
initialBalance + transferAmount,
102+
'Final balance should be increased by transfer amount'
103+
);
104+
});
105+
});
106+
});

e2e/scaffolding/ethereum.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,26 @@ import { secp256k1 } from '@noble/curves/secp256k1';
88
import { Keyring } from '@polkadot/api';
99
import { Keypair } from '@polkadot/util-crypto/types';
1010
import { Address20MultiAddress } from './helpers';
11+
import { H160 } from '@polkadot/types/interfaces';
12+
13+
/**
14+
* Create a partial KeyringPair from an Ethereum address
15+
*/
16+
export function ethereumAddressToKeyringPair(ethereumAddress: H160): KeyringPair {
17+
return {
18+
type: 'ethereum',
19+
address: ethereumAddress.toHex(),
20+
addressRaw: ethereumAddress,
21+
} as unknown as KeyringPair;
22+
}
1123

1224
/**
1325
* Returns unified 32 bytes SS58 accountId
1426
* @param pair
1527
*/
1628
export function getUnifiedAddress(pair: KeyringPair): string {
1729
if ('ethereum' === pair.type) {
18-
const etheAddressHex = ethereumEncode(pair.publicKey);
30+
const etheAddressHex = ethereumEncode(pair.publicKey || pair.address);
1931
return getSS58AccountFromEthereumAccount(etheAddressHex);
2032
}
2133
if (pair.type === 'ecdsa') {
@@ -71,7 +83,7 @@ export function getAccountId20MultiAddress(pair: KeyringPair): Address20MultiAdd
7183
if (pair.type !== 'ethereum') {
7284
throw new Error(`Only ethereum keys are supported!`);
7385
}
74-
const etheAddress = ethereumEncode(pair.publicKey);
86+
const etheAddress = ethereumEncode(pair.publicKey || pair.address);
7587
const ethAddress20 = Array.from(hexToU8a(etheAddress));
7688
return { Address20: ethAddress20 };
7789
}
@@ -86,15 +98,14 @@ export function getKeyringPairFromSecp256k1PrivateKey(secretKey: Uint8Array): Ke
8698
secretKey,
8799
publicKey,
88100
};
89-
const keyring = new Keyring({ type: 'ethereum' });
90-
return keyring.addFromPair(keypair, undefined, 'ethereum');
101+
return new Keyring({ type: 'ethereum' }).createFromPair(keypair, undefined, 'ethereum');
91102
}
92103

93104
/**
94105
* converts an ethereum account to SS58 format
95106
* @param accountId20Hex
96107
*/
97-
function getSS58AccountFromEthereumAccount(accountId20Hex: string): string {
108+
export function getSS58AccountFromEthereumAccount(accountId20Hex: string): string {
98109
const addressBytes = hexToU8a(accountId20Hex);
99110
const suffix = new Uint8Array(12).fill(0xee);
100111
const result = new Uint8Array(32);

pallets/msa/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ parity-scale-codec = { workspace = true, features = ["derive"] }
1818
frame-benchmarking = { workspace = true, optional = true }
1919
frame-support = { workspace = true }
2020
frame-system = { workspace = true }
21+
lazy_static = { workspace = true }
2122

2223
scale-info = { workspace = true, features = ["derive"] }
2324
sp-core = { workspace = true }
@@ -42,6 +43,8 @@ parking_lot = { workspace = true }
4243

4344
[features]
4445
default = ["std"]
46+
frequency = []
47+
frequency-testnet = []
4548
runtime-benchmarks = [
4649
"frame-benchmarking/runtime-benchmarks",
4750
"pallet-schemas/runtime-benchmarks",

pallets/msa/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ All users on Frequency must have an MSA in order to:
1717
Once a user creates an MSA, they are assigned an MSA Id, a unique number the time of creation with one or more keys attached for control.
1818
(A control key may only be attached to ONE MSA at any single point in time.)
1919

20+
#### MSA Id and Addresses
21+
22+
Each MSA Id has a unique 20-byte address associated with it. This address can be queried using an MSA pallet runtime call, or computed using the following algorithm:
23+
```ignore
24+
Address = keccak256(0xD9 + <MSA Id as 8-byte big-endian bytes> + keccak256(b"MSA Generated"))[12..]
25+
```
26+
2027
### Actions
2128

2229
The MSA pallet provides for:
@@ -70,3 +77,13 @@ Note: May be restricted based on node settings and configuration.
7077
\* Must be enabled with off-chain indexing
7178

7279
See [Rust Docs](https://frequency-chain.github.io/frequency/pallet_msa_rpc/trait.MsaApiServer.html) for more details.
80+
81+
### Runtime API
82+
83+
| Name | Description | Call | Runtime Added | MSA Runtime API Version Added |
84+
| ------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------------------------- |
85+
| Has Delegation | Check to see if a delegation existed between the given delegator and provider at a given block | ['hasDelegation'](https://frequency-chain.github.io/frequency/pallet_msa_runtime_api/trait.MsaRuntimeApi.html#method.has_delegation) | 1 | 1 |
86+
| Get Granted Schemas by MSA ID | Get the list of schema permission grants (if any) that exist in any delegation between the delegator and provider. | ['getGrantedSchemasByMsaId'](https://frequency-chain.github.io/frequency/pallet_msa_runtime_api/trait.MsaRuntimeApi.html#method.get_granted_schemas_by_msa_id) | 1 | 1 |
87+
| Get All Granted Delegations by MSA ID | Get the list of all delegated providers with schema permission grants (if any) that exist in any delegation between the delegator and provider. | ['getAllGrantedDelegationsByMsaId'](https://frequency-chain.github.io/frequency/pallet_msa_runtime_api/trait.MsaRuntimeApi.html#method.get_all_granted_delegations_by_msa_id) | 83 | 2 |
88+
| Get Ethereum Address for MSA ID | Get the Ethereum address of the given MSA. | ['getEthereumAddressForMsaId'](https://frequency-chain.github.io/frequency/pallet_msa_runtime_api/trait.MsaRuntimeApi.html#method.get_ethereum_address_for_msa_id) | 156 | 3 |
89+
| Validate Ethereum Address for MSA ID | Validate if the given Ethereum address is associated with the given MSA. | ['validateEthAddressForMsa'](https://frequency-chain.github.io/frequency/pallet_msa_runtime_api/trait.MsaRuntimeApi.html#method.validate_eth_address_for_msa) | 156 | 3 |

pallets/msa/src/lib.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use frame_support::{
3636
pallet_prelude::*,
3737
traits::IsSubType,
3838
};
39+
use lazy_static::lazy_static;
3940
use parity_scale_codec::{Decode, Encode};
4041

4142
use common_runtime::signature::check_signature;
@@ -48,14 +49,15 @@ use common_primitives::{
4849
msa::{
4950
Delegation, DelegationValidator, DelegatorId, MsaLookup, MsaValidator, ProviderId,
5051
ProviderLookup, ProviderRegistryEntry, SchemaGrant, SchemaGrantValidator,
51-
SignatureRegistryPointer,
52+
SignatureRegistryPointer, H160,
5253
},
5354
node::ProposalProvider,
5455
schema::{SchemaId, SchemaValidator},
5556
};
5657
use frame_system::pallet_prelude::*;
5758
use scale_info::TypeInfo;
5859
use sp_core::crypto::AccountId32;
60+
use sp_io::hashing::keccak_256;
5961
#[allow(deprecated)]
6062
#[allow(unused)]
6163
use sp_runtime::{
@@ -89,6 +91,7 @@ mod tests;
8991
pub mod types;
9092

9193
pub mod weights;
94+
9295
#[frame_support::pallet]
9396
pub mod pallet {
9497
use super::*;
@@ -1357,6 +1360,67 @@ impl<T: Config> Pallet<T> {
13571360
Ok(result)
13581361
}
13591362

1363+
/// Converts an MSA ID into a synthetic Ethereum address (raw 20-byte format) by
1364+
/// taking the last 20 bytes of the keccak256 hash of the following:
1365+
/// [0..1]: 0xD9 (first byte of the keccak256 hash of the domain prefix "Frequency")
1366+
/// [1..9]: u64 (big endian)
1367+
/// [9..41]: keccack256("MSA Generated")
1368+
pub fn msa_id_to_eth_address(id: MessageSourceId) -> H160 {
1369+
/// First byte of the keccak256 hash of the domain prefix "Frequency"
1370+
/// This "domain separator" ensures that the generated address will not collide with Ethereum addresses
1371+
/// generated by the standard 'CREATE2' opcode.
1372+
const DOMAIN_PREFIX: u8 = 0xD9;
1373+
1374+
lazy_static! {
1375+
/// Salt used to generate MSA addresses
1376+
static ref MSA_ADDRESS_SALT: [u8; 32] = keccak_256(b"MSA Generated");
1377+
}
1378+
let input_value = id.to_be_bytes();
1379+
1380+
let mut hash_input = [0u8; 41];
1381+
hash_input[0] = DOMAIN_PREFIX;
1382+
hash_input[1..9].copy_from_slice(&input_value);
1383+
hash_input[9..].copy_from_slice(&(*MSA_ADDRESS_SALT));
1384+
1385+
let hash = keccak_256(&hash_input);
1386+
H160::from_slice(&hash[12..])
1387+
}
1388+
1389+
/// Returns a boolean indicating whether the given Ethereum address was generated from the given MSA ID.
1390+
pub fn validate_eth_address_for_msa(address: &H160, msa_id: MessageSourceId) -> bool {
1391+
let generated_address = Self::msa_id_to_eth_address(msa_id);
1392+
*address == generated_address
1393+
}
1394+
1395+
/// Converts a 20-byte synthetic Ethereum address into a checksummed string format,
1396+
/// using ERC-55 checksum rules.
1397+
/// Formats a 20-byte address into an EIP-55 checksummed `0x...` string.
1398+
pub fn eth_address_to_checksummed_string(address: &H160) -> alloc::string::String {
1399+
let addr_bytes = address.0;
1400+
let addr_hex = hex::encode(addr_bytes);
1401+
let hash = keccak_256(addr_hex.as_bytes());
1402+
1403+
let mut result = alloc::string::String::with_capacity(42);
1404+
result.push_str("0x");
1405+
1406+
for (i, c) in addr_hex.chars().enumerate() {
1407+
let hash_byte = hash[i / 2];
1408+
let bit = if i % 2 == 0 { (hash_byte >> 4) & 0xf } else { hash_byte & 0xf };
1409+
1410+
result.push(if c.is_ascii_hexdigit() && c.is_ascii_alphabetic() {
1411+
if bit >= 8 {
1412+
c.to_ascii_uppercase()
1413+
} else {
1414+
c
1415+
}
1416+
} else {
1417+
c
1418+
});
1419+
}
1420+
1421+
result
1422+
}
1423+
13601424
/// Adds a signature to the `PayloadSignatureRegistryList`
13611425
/// Check that mortality_block is within bounds. If so, proceed and add the new entry.
13621426
/// Raises `SignatureAlreadySubmitted` if the signature exists in the registry.

pallets/msa/src/runtime-api/src/lib.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ sp_api::decl_runtime_apis! {
2929
/// Runtime Version for MSAs
3030
/// - MUST be incremented if anything changes
3131
/// - See: https://paritytech.github.io/polkadot/doc/polkadot_primitives/runtime_api/index.html
32-
#[api_version(2)]
32+
#[api_version(3)]
3333

3434
/// Runtime API definition for [MSA](../pallet_msa/index.html)
3535
pub trait MsaRuntimeApi<AccountId> where
@@ -45,5 +45,12 @@ sp_api::decl_runtime_apis! {
4545
/// Get the list of all delegated providers with schema permission grants (if any) that exist in any delegation between the delegator and provider
4646
/// The returned list contains both schema id and the block number at which permission was revoked (0 if currently not revoked)
4747
fn get_all_granted_delegations_by_msa_id(delegator: DelegatorId) -> Vec<DelegationResponse<SchemaId, BlockNumber>>;
48+
49+
/// Get the Ethereum address of the given MSA.
50+
/// The address is returned as both a 20-byte binary address and a hex-encoded checksummed string (ERC-55).
51+
fn get_ethereum_address_for_msa_id(msa_id: MessageSourceId) -> AccountId20Response;
52+
53+
/// Validate if the given Ethereum address is associated with the given MSA
54+
fn validate_eth_address_for_msa(eth_address: &H160, msa_id: MessageSourceId) -> bool;
4855
}
4956
}

0 commit comments

Comments
 (0)