hive-multisign
Version:
A typescript utility for multi-signature transactions on the Hive blockchain
316 lines (286 loc) • 11.3 kB
text/typescript
import { AuthorityType, Client, KeyRole, Operation, PrivateKey, PublicKey, SignedTransaction, Transaction, TransactionConfirmation } from '@hiveio/dhive'
import { Buffer } from 'buffer'
const client = new Client(['https://hive-api.arcange.eu', 'https://hived.emre.sh', 'https://api.hive.blog', 'https://api.openhive.network'])
export interface IAccountAuthority {
weight_threshold: number
account_auths?: [string, number][]
key_auths?: [string, number][]
}
/**
* Get account information
* @param usernames account usernames
* @returns
*/
const getAccounts = async (usernames: string[]): Promise<any> => {
return client.call('condenser_api', 'get_accounts', [usernames])
}
/**
* Get user authorities (owner, active, posting)
* @param username
* @returns
*/
export const getUserAuthorities = async (username: string): Promise<{ owner: IAccountAuthority; active: IAccountAuthority; posting: IAccountAuthority }> => {
const info = await getAccounts([username])
if (info.length == 0) {
throw new Error(`Account @${username} not found`)
}
const account = info[0]
return {
owner: account.owner,
active: account.active,
posting: account.posting
}
}
/**
* Get the public memo key for the specified accounts
* @param username account username
* @returns
*/
const getMemoAndAccount = async (
username: string
): Promise<{ owner: IAccountAuthority; active: IAccountAuthority; posting: IAccountAuthority; memo: string }> => {
let res: string[] = []
const info = await getAccounts([username])
if (info.length == 0) {
throw new Error(`Account @${username} not found`)
}
return {
owner: info[0].owner,
active: info[0].active,
posting: info[0].posting,
memo: info[0].memo_key
}
}
/**
* Generate many private keys for the account. Each private key is associated with one password.
* @param username account username
* @param passwords array of passwords, one of which must be the account's master password
* @param role key role (e.g. 'posting')
*/
export const generatePrivateKeys = (username: string, passwords: string[], role: KeyRole): PrivateKey[] => {
return passwords.map((pass: string) => PrivateKey.fromLogin(username, pass, role))
}
/**
* Generate the public keys associated with the specified private keys.
* @param privateKeys private keys
* @returns
*/
export const generatePublicKeys = (privateKeys: string[] | PrivateKey[]): string[] => {
return privateKeys.map((key) => PrivateKey.from(key.toString()).createPublic().toString())
}
/**
* Generate the object needed to send an account authority update.
* @param username account username
* @param threshold weight threshold
* @param accountAuths account authorities [username, weight]
* @param keyAuths key authorities [public_key, weight]
* @param role key role (e.g. 'posting')
* @param replace Replace the old authorities instead of appending to them
* @returns
*/
export const generateAuthChangeObject = async (
username: string,
threshold: number,
accountAuths: [string, number][],
keyAuths: [string, number][],
role: KeyRole,
replace?: { account?: boolean; key?: boolean }
): Promise<{
account: string
owner?: AuthorityType | undefined
active?: AuthorityType | undefined
posting?: AuthorityType | undefined
memo_key: string | PublicKey
json_metadata: string
}> => {
const accountInfo = await getMemoAndAccount(username)
let newAccountAuths = Array.from(accountAuths)
let newKeyAuths = Array.from(keyAuths)
// Make sure the user is not sending public keys
for (let i = 0; i < newKeyAuths.length; i++) {
if (newKeyAuths[i][0][0] == '5') {
newKeyAuths[i][0] = generatePublicKeys([newKeyAuths[i][0]])[0]
}
}
// If shouldn't replace account auths merge with the old ones
if (!replace?.account) {
const oldAccountAuths = (accountInfo[role] as IAccountAuthority).account_auths as [string, number][]
newAccountAuths = mergeAuthWithoutDuplicates(oldAccountAuths, accountAuths)
}
// If shouldn't replace key auths merge with the old ones
if (!replace?.key) {
const oldKeyAuths = (accountInfo[role] as IAccountAuthority).key_auths as [string, number][]
newKeyAuths = mergeAuthWithoutDuplicates(oldKeyAuths, keyAuths)
}
return {
account: username,
[role]: {
weight_threshold: threshold,
account_auths: newAccountAuths.sort((a, b) => (a[0] > b[0] ? 1 : -1)),
key_auths: newKeyAuths.sort((a, b) => (a[0] > b[0] ? 1 : -1))
},
json_metadata: '',
memo_key: accountInfo.memo as string
}
}
/**
* Merge authority pairs (account auths or key auths) considering newAuths the correct value in case of duplicates.
* @param oldAuths old authority pairs
* @param newAuths new authority pairs
* @returns
*/
const mergeAuthWithoutDuplicates = (oldAuths: [string, number][], newAuths: [string, number][]): [string, number][] => {
const merged = Array.from(newAuths)
const setNew = new Set(newAuths.map((pair) => pair[0]))
// Add old auths that aren't duplicates
for (const pair of oldAuths) {
if (!setNew.has(pair[0])) {
merged.push(pair)
}
}
return merged
}
/**
* Replace the chosen authority with the keys specified.
* @param username account username
* @param privateActive private active key
* @param threshold wight threshold required to broadcast a transaction
* @param keyAuths [public_key, weight]
* @param replace Replace the old authorities instead of appending to them
* @returns
*/
export const updateAuths = async (
username: string,
privateActive: string,
threshold: number,
accountAuths: [string, number][],
keyAuths: [string, number][],
role: KeyRole,
replace?: { account?: boolean; key?: boolean }
): Promise<TransactionConfirmation> => {
let sum = 0
for (const keyWeight of keyAuths) {
sum += keyWeight[1]
}
if (sum < threshold) {
throw new Error(`Threshold (${threshold}) cannot be smaller than the sum of the weights (${sum})`)
}
const payload = await generateAuthChangeObject(username, threshold, [], keyAuths, role, replace)
return client.broadcast.updateAccount(payload, PrivateKey.from(privateActive))
}
/**
* Add an authority for each password. Set the threshold such that a signature from each of them is required.
* @param username account username
* @param privateActive private active key
* @param passwords array of passowrds
* @param role key role (e.g. 'posting')
* @returns
*/
export const assignUnifromAuth = (username: string, privateActive: string, passwords: string[], role: KeyRole) => {
const keyAuths = generatePrivateKeys(username, passwords, role).map((key: PrivateKey) => [PrivateKey.from(key.toString()).createPublic().toString(), 1])
return updateAuths(username, privateActive, passwords.length, [], keyAuths as [string, number][], role, { key: true })
}
/**
* Prepare a transaction that needs to be signed.
* @param operations operations
* @param expireTime expire time [ms] in [0, 3590*1000]
* @returns
*/
export const prepareTrx = async (operations: Operation[], expireTime: number = 1000 * 3590): Promise<Transaction> => {
const props = await client.database.getDynamicGlobalProperties()
const ref_block_num = props.head_block_number & 0xffff
const ref_block_prefix = Buffer.from(props.head_block_id, 'hex').readUInt32LE(4)
const expiration = new Date(Date.now() + expireTime).toISOString().slice(0, -5)
const extensions: any = []
return {
expiration,
extensions,
operations,
ref_block_num,
ref_block_prefix
}
}
/**
* Check if the specified transaction has enough signatures to be broadcasted to the blockchain.
* @param trx singed transaction
* @returns
*/
export const hasEnoughSignatures = async (trx: SignedTransaction): Promise<boolean> => {
try {
return await client.database.verifyAuthority(trx)
} catch (err) {
return false
}
}
/**
* Sign transaction.
* @param trx transaction
* @param key private key
* @returns
*/
const signTrx = (trx: Transaction, key: PrivateKey) => {
return client.broadcast.sign(trx, key)
}
/**
* Broadcast a signed transaction.
* @param signedTrx signed transaction
* @returns
*/
export const sendTrx = async (signedTrx: SignedTransaction): Promise<TransactionConfirmation> => {
return client.broadcast.send(signedTrx)
}
/**
* Test the multisign system. We reccomend testing on a disposable account.
* The test changes the posting authorities, requiring 2 signatures to send transactions and sends a test transaction to
* verify it works.
* IMPORTANT: this method will change your posting authority keys, if you want to go back to the original one you need to run
* the function 'resetOriginalKey'.
* @param username account username
* @param privateActive private active key
* @returns
*/
export const demo = async (username: string, privateActive: string) => {
// Choose some passwords
const passwords: string[] = ['test123', 'test456']
// Create private keys for those passwords
const newPrivates: PrivateKey[] = generatePrivateKeys(username, passwords, 'posting')
// Find the associated public keys
const newPublics: string[] = newPrivates.map((key) => PrivateKey.from(key.toString()).createPublic().toString())
// Create an array of [public_key, weight], where the weight specifies how much the key's vote is worth
// In this case our 2 keys will have both a weight of 2
const keyAuths: [string, number][] = newPublics.map((key) => [key, 2])
// Update the account. Use threshold = 4, so both signatures (2+2) are required for the transaction to be sent
await updateAuths(username, privateActive, 4, [], keyAuths, 'posting', { key: true })
// Create an operation to check that the system works
const operation: Operation = [
'custom_json',
{
required_auths: [],
required_posting_auths: [username],
id: 'hive-multisign test',
json: JSON.stringify({ message: 'Transaction broadcasted with multiple signatures' })
}
]
// Prepare the transaction to be sent
const trx = await prepareTrx([operation])
// Sign the transaction with all the keys
let signedTrx: any = {}
// Sign with the first private key
signedTrx = client.broadcast.sign(trx, PrivateKey.from(newPrivates[0].toString()))
// Sign with the second private key the partially signed trx
signedTrx = client.broadcast.sign(signedTrx, PrivateKey.from(newPrivates[1].toString()))
// Send the transaction
return client.broadcast.send(signedTrx as SignedTransaction)
}
/**
* Reset your posting key to the original. If you can't find your master password don't worry, choose a password now and
* then go to your wallet and change your posting key to the one generated here.
* @param username account username
* @param privateActive private active key
* @param masterPassword master password, it was given to you along with the other keys
*/
export const resetOriginalKey = async (username: string, privateActive: string, masterPassword: string) => {
const newPrivate: PrivateKey = generatePrivateKeys(username, [masterPassword], 'posting')[0]
await updateAuths(username, privateActive, 1, [], [[PrivateKey.from(newPrivate.toString()).createPublic().toString(), 1]], 'posting', { key: true })
console.log(`Your new private key is: ${newPrivate.toString()}`)
}