Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transaction builder draft #1258

Draft
wants to merge 6 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/renderer/entities/transactionBuilder/lib/compound-wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
CallBuilder,
TransactionBuilder,
TransactionVisitor
} from "@entities/transactionBuilder/model/transaction-builder";
import {AccountInWallet, Wallet} from "@shared/core/types/wallet";
import {Account} from "@shared/core";
import {ApiPromise} from "@polkadot/api";
import {BaseTxInfo, OptionsWithMeta, UnsignedTransaction} from "@substrate/txwrapper-polkadot";
import {SubmittableExtrinsic} from "@polkadot/api/types";
import {LeafTransactionBuilder} from "@entities/transactionBuilder/lib/leaf";

export class CompoundWalletTransactionBuilder implements TransactionBuilder {

readonly wallet: Wallet
readonly allChildrenAccounts: Account[]

#selectedSChildrenAccounts: Account[]

readonly api: ApiPromise

#inner: TransactionBuilder

constructor(
api: ApiPromise,
wallet: Wallet,
childrenAccounts: Account[],
) {
this.wallet = wallet
this.allChildrenAccounts = childrenAccounts
this.api = api

if (childrenAccounts.length == 0) throw new Error("Empty children accounts list")

const firstChild = childrenAccounts[0]
const firstChildInWallet: AccountInWallet = {
wallet: wallet,
account: firstChild
}
this.#selectedSChildrenAccounts = [firstChild]
// We cannot have complex structure for wallets with multiple accounts per chain
this.#inner = new LeafTransactionBuilder(api, firstChildInWallet)
}

effectiveCallBuilder(): CallBuilder {
return this.#inner.effectiveCallBuilder()
}

visitAll(visitor: TransactionVisitor): void {
this.visitSelf(visitor)

this.#inner.visitAll(visitor)
}

visitSelf(visitor: TransactionVisitor) {
if (visitor.visitCompoundWallet == undefined) return

visitor.visitCompoundWallet({
wallet: this.wallet,
allChildrenAccounts: this.allChildrenAccounts,
selectedChildrenAccounts: this.#selectedSChildrenAccounts,
updateSelectedChildren: this.#updateSelectedSChildren,
})
}

unsignedTransaction(options: OptionsWithMeta, info: BaseTxInfo): Promise<UnsignedTransaction> {
return this.#inner.unsignedTransaction(options, info)
}

submittableExtrinsic(): Promise<SubmittableExtrinsic<"promise"> | null> {
return this.#inner.submittableExtrinsic()
}

#updateSelectedSChildren(selectedChildren: Account[]) {
// No need to re-create `inner` since it is the leaf and won't change anyway
this.#selectedSChildrenAccounts = selectedChildren
}
}
75 changes: 75 additions & 0 deletions src/renderer/entities/transactionBuilder/lib/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {Account, AccountId, MultisigAccount, Wallet, WalletType} from "@shared/core";
import {TransactionBuilder} from "@entities/transactionBuilder/model/transaction-builder";
import {AccountInWallet} from "@shared/core/types/wallet";
import keyBy from 'lodash/keyBy';
import {walletUtils} from "@entities/wallet";
import {LeafTransactionBuilder} from "@entities/transactionBuilder/lib/leaf";
import {MultisigTransactionBuilder} from "@entities/transactionBuilder/lib/multisig";
import {groupBy} from "lodash";
import {CompoundWalletTransactionBuilder} from "@entities/transactionBuilder/lib/compound-wallet";
import {ApiPromise} from "@polkadot/api";

export type NestedTransactionBuilderFactory = (shard: AccountInWallet) => TransactionBuilder

export function createTransactionBuilder(
selectedWallet: Wallet,
selectedAccounts: Account[],
allWallets: Wallet[],
allAccounts: Account[],
api: ApiPromise
): TransactionBuilder {

const walletsById = keyBy(allWallets, 'id')
const accountsByAccountId = groupBy(allAccounts, "accountId")

const createInner: NestedTransactionBuilderFactory = (accountInWallet: AccountInWallet) => {
const wallet = accountInWallet.wallet

switch (walletUtils.getWalletFamily(wallet)) {
case WalletType.MULTISIG:
const multisigAccount = accountInWallet.account as MultisigAccount
const matchingAccounts = multisigAccount.signatories.flatMap((signatory) => {
return findMatchingAccounts(signatory.accountId, walletsById, accountsByAccountId)
})

return new MultisigTransactionBuilder(
api,
multisigAccount.threshold,
multisigAccount.signatories.map((signatory) => signatory.accountId),
matchingAccounts,
createInner
)

case WalletType.WATCH_ONLY:
throw new Error("Signing with Watch only is not allowed")

case WalletType.WALLET_CONNECT:
case WalletType.NOVA_WALLET:
case WalletType.POLKADOT_VAULT:
return new LeafTransactionBuilder(api, accountInWallet)
}
}

if (selectedAccounts.length > 1) {
return new CompoundWalletTransactionBuilder(api, selectedWallet, selectedAccounts)
} else {
return createInner({ wallet: selectedWallet, account: selectedAccounts[0] })
}
}

function findMatchingAccounts(
accountId: AccountId,
allWalletsById: Record<number, Wallet>,
allAccountsById: Record<AccountId, Account[]>
): AccountInWallet[] {
const idMatchingAccounts = allAccountsById[accountId]

return idMatchingAccounts.map((account) => {
const wallet = allWalletsById[account.walletId]
const accountInWallet: AccountInWallet = {wallet, account}
return accountInWallet
}
).filter((accountInWallet) => {
!walletUtils.isWatchOnly(accountInWallet.wallet)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {AccountId} from "@shared/core";


export interface AmountReduction {

reductionFor(accountId: AccountId): bigint

totalReduction(): bigint
}

export interface AmountReductionBuilder {

addReductionAmount(payer: AccountId, amount: bigint): void

build(): AmountReduction
}

export function buildAmountReduction(): AmountReductionBuilder {
const amountByAccount: Record<AccountId, bigint> = {}
let total: bigint = BigInt(0)

const addReductionAmount = (payer: AccountId, amount: bigint) => {
const currentAmount = amountByAccount[payer] || BigInt(0)
amountByAccount[payer] = currentAmount + amount
total += amount
}

const build = () => createAmountReduction(amountByAccount, total)

return {
addReductionAmount,
build
}
}

function createAmountReduction(
amountByAccount: Record<AccountId, bigint>,
total: bigint
): AmountReduction {
return {
reductionFor(accountId: AccountId): bigint {
return amountByAccount[accountId] || BigInt(0)
},
totalReduction(): bigint {
return total
}
}
}
88 changes: 88 additions & 0 deletions src/renderer/entities/transactionBuilder/lib/helpers/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {AccountsInWallet, asMany} from "@shared/core/types/wallet";
import {
CompoundWalletVisit,
LeafVisit,
MultisigVisit,
TransactionBuilder
} from "@entities/transactionBuilder/model/transaction-builder";
import {AmountReduction, buildAmountReduction} from "@entities/transactionBuilder/lib/helpers/amount-reduction";
import {ApiPromise} from "@polkadot/api";
import {UnsignedTransaction} from "@substrate/txwrapper-polkadot";
import {createTxMetadatas, toAddress} from "@shared/lib/utils";

export function getSigningAccounts(transactionBuilder: TransactionBuilder): AccountsInWallet | undefined {
let signingAccounts: AccountsInWallet | undefined = undefined

transactionBuilder.visitSelf({
visitLeaf(visit: LeafVisit) {
signingAccounts = asMany(visit.account)
},

visitMultisig(visit: MultisigVisit) {
signingAccounts = asMany(visit.selectedSignatory)
},

visitCompoundWallet(visit: CompoundWalletVisit) {
signingAccounts = {
wallet: visit.wallet,
accounts: visit.selectedChildrenAccounts
}
}
})

return signingAccounts
}

export async function getTransactionFee(transactionBuilder: TransactionBuilder): Promise<AmountReduction | undefined> {
const signingAccounts = getSigningAccounts(transactionBuilder)
if (signingAccounts == undefined) return undefined

const singleTx = await transactionBuilder.submittableExtrinsic()
const singleTxFee = await singleTx?.paymentInfo(signingAccounts.accounts[0].accountId)
if (singleTxFee == undefined) return undefined

const reductionBuilder = buildAmountReduction()

signingAccounts.accounts.forEach((account) => {
reductionBuilder.addReductionAmount(account.accountId, singleTxFee.partialFee.toBigInt())
})

return reductionBuilder.build()
}

export async function getDeposits(transactionBuilder: TransactionBuilder): Promise<AmountReduction> {
const reductionBuilder = buildAmountReduction()

const multisigDeposit = await getMultisigDeposit(transactionBuilder.api)

transactionBuilder.visitAll({
visitMultisig(visit: MultisigVisit) {
// TODO check who pays deposit in case multisig is wrapped in some other structure, like proxy
Copy link
Contributor

Choose a reason for hiding this comment

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

If that helps, the submitter always pays the deposit for the multisig transaction state. Now depending on the action performed, there may be other deposit required. for instance:

  • A multisig is creating a proxy. The submitter needs ~20 DOTs for the multisig tx, but the Multisig that will then create the proxy will also need 20 DOTs for the proxy creation deposit
  • A Multisig with pure proxy sets an identity. The submitter needs ~20 DOTs for the multisig tx, but the Multisig that will then set an will also need 20 DOTs for the identity deposit

TL;DR, the depositor/submitter needs the multisig tx deposit, but the transaction may fail when being submitted by the last signatory, because of some other deposit.

reductionBuilder.addReductionAmount(visit.selectedSignatory.account.accountId, multisigDeposit)
},
})

return reductionBuilder.build()
}

export async function getUnsignedTransactions(transactionBuilder: TransactionBuilder): Promise<UnsignedTransaction[]> {
const signers = getSigningAccounts(transactionBuilder)
if (signers == undefined) throw new Error("No signing accounts found")

const addresses = signers.accounts.map((signer) => {
return toAddress(signer.accountId, {prefix: transactionBuilder.chain.addressPrefix})
})

const txMetadatas = await createTxMetadatas(addresses, transactionBuilder.api)

return Promise.all(
txMetadatas.map(({options, info}) => {
return transactionBuilder.unsignedTransaction(options, info)
})
)
}

async function getMultisigDeposit(api: ApiPromise): Promise<bigint> {
const {depositFactor, depositBase} = api.consts.multisig;
return depositFactor.toBigInt() * depositBase.toBigInt()
}
87 changes: 87 additions & 0 deletions src/renderer/entities/transactionBuilder/lib/leaf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
CallBuilder, CallBuilding,
TransactionBuilder,
TransactionVisitor
} from "@entities/transactionBuilder/model/transaction-builder";
import {AccountInWallet} from "@shared/core/types/wallet";
import {ApiPromise} from "@polkadot/api";
import {BaseTxInfo, methods, OptionsWithMeta, UnsignedTransaction} from "@substrate/txwrapper-polkadot";
import {SubmittableExtrinsic} from "@polkadot/api/types";

export class LeafTransactionBuilder implements TransactionBuilder, CallBuilder {

currentCalls: CallBuilding[]

api: ApiPromise

readonly accountInWallet: AccountInWallet

constructor(api: ApiPromise, accountInWallet: AccountInWallet) {
this.currentCalls = []
this.api = api

this.accountInWallet = accountInWallet
}

effectiveCallBuilder(): CallBuilder {
return this
}

visitAll(visitor: TransactionVisitor): void {
this.visitSelf(visitor)
}

visitSelf(visitor: TransactionVisitor) {
if (visitor.visitLeaf == undefined) return

visitor.visitLeaf({ account: this.accountInWallet })
}

addCall(call: CallBuilding): void {
this.currentCalls.push(call)
}

resetCalls(): void {
this.currentCalls = []
}

setCall(call: CallBuilding): void {
this.currentCalls = [call]
}

initFrom(callBuilder: CallBuilder): void {
this.currentCalls = callBuilder.currentCalls
}

async unsignedTransaction(options: OptionsWithMeta, info: BaseTxInfo): Promise<UnsignedTransaction> {
const nestedUnsignedTxs = this.currentCalls.map(call => call.signing(info, options))

if (nestedUnsignedTxs.length == 0) throw new Error("Cannot sign empty transaction")

let maybeWrappedInBatch: UnsignedTransaction
if (nestedUnsignedTxs.length > 1) {
const innerMethods = nestedUnsignedTxs.map((nestedUnsignedTx) => nestedUnsignedTx.method)
maybeWrappedInBatch = methods.utility.batch({calls: innerMethods,}, info, options)
} else {
maybeWrappedInBatch = nestedUnsignedTxs[0]
}

return maybeWrappedInBatch
}

async submittableExtrinsic(): Promise<SubmittableExtrinsic<"promise"> | null> {
const feeCalls = this.currentCalls.map(call => call.fee)

if (feeCalls.length == 0) return null

let maybeWrappedInBatch: SubmittableExtrinsic<"promise">
if (feeCalls.length > 1) {
maybeWrappedInBatch = this.api.tx.utility.batch(feeCalls)
} else {
maybeWrappedInBatch = feeCalls[0]
}

return maybeWrappedInBatch
}

}
Loading