Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,23 @@ export async function createVotingRegTx({
if (!baseAddrObj) {
throw new Error('Failed to convert base address to Address')
}
const paymentAddressCIP36 = baseAddrObj.toBech32(undefined)
if (!paymentAddressCIP36) {
throw new Error('Failed to convert payment address to bech32')
// Convert addresses to hex format (bech32 strings are too long for metadata)
const paymentAddressHex = baseAddrObj.toHex()
if (!paymentAddressHex) {
throw new Error('Failed to convert payment address to hex')
}

// Derive reward address from base address
const rewardAddr = baseAddrObj
const rewardAddress = rewardAddr.toBech32(undefined)
if (!rewardAddress) {
throw new Error('Failed to convert reward address to bech32')
const rewardAddressHex = rewardAddr.toHex()
if (!rewardAddressHex) {
throw new Error('Failed to convert reward address to hex')
}

// Estimate fee for voting registration transaction
// Voting registration transactions are typically small (~400-600 bytes)
const estimatedTxSize = 600 // bytes - conservative estimate
// CIP-36 metadata with hex addresses can be larger (~600-800 bytes)
// Use a more conservative estimate to account for metadata size
const estimatedTxSize = supportsCIP36 ? 800 : 600 // bytes - CIP-36 has larger metadata
const estimatedFee =
BigInt(protocolParams.linearFee.constant) +
BigInt(protocolParams.linearFee.coefficient) * BigInt(estimatedTxSize)
Expand All @@ -112,7 +114,8 @@ export async function createVotingRegTx({
// 1. Fee for the transaction
// 2. Minimum UTXO value for the change output (at least 1 ADA)
const minUtxoValue = BigInt(protocolParamsConfig.minimumUtxoVal || '1000000') // Base min UTXO (1 ADA)
const feeBuffer = BigInt('100000') // 0.1 ADA buffer for fee estimation variance
// Increase fee buffer for CIP-36 to account for larger metadata and fee calculation variance
const feeBuffer = supportsCIP36 ? BigInt('200000') : BigInt('100000') // 0.2 ADA for CIP-36, 0.1 ADA for CIP-15
const requiredAda = (estimatedFee + minUtxoValue + feeBuffer).toString()

// Select only necessary UTXOs to cover fees
Expand All @@ -125,34 +128,26 @@ export async function createVotingRegTx({
builderState = addInputs(builderState, selectedUtxos)

// Create and add voting metadata
const votingPublicKeyBech32 = votingPublicKey.toBech32()
if (!votingPublicKeyBech32) {
throw new Error('Failed to convert voting public key to bech32')
}
const stakingPublicKeyBech32 = stakingPublicKey.toBech32()
if (!stakingPublicKeyBech32) {
throw new Error('Failed to convert staking public key to bech32')
}
const rewardAddressBranded =
typeof rewardAddress === 'string'
? (rewardAddress as Address)
: rewardAddress
const paymentAddressBranded =
typeof paymentAddressCIP36 === 'string'
? (paymentAddressCIP36 as Address)
: paymentAddressCIP36
// Convert public keys to hex format (bech32 strings are too long for metadata)
const votingPublicKeyHex = Buffer.from(votingPublicKey.asBytes()).toString(
'hex',
)
const stakingPublicKeyHex = Buffer.from(stakingPublicKey.asBytes()).toString(
'hex',
)

const votingMetadata = supportsCIP36
? createCIP36VotingMetadata(
votingPublicKeyBech32 as PublicKeyHex,
stakingPublicKeyBech32 as PublicKeyHex,
rewardAddressBranded,
votingPublicKeyHex,
stakingPublicKeyHex,
rewardAddressHex,
nonce,
paymentAddressBranded,
paymentAddressHex,
)
: createCIP15VotingMetadata(
votingPublicKeyBech32 as PublicKeyHex,
stakingPublicKeyBech32 as PublicKeyHex,
rewardAddressBranded,
votingPublicKeyHex,
stakingPublicKeyHex,
rewardAddressHex,
nonce,
)

Expand Down
81 changes: 81 additions & 0 deletions mobile/packages/tx/transaction-builder/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,87 @@ export async function buildTransaction(
})

try {
// If transaction has metadata, estimate witness set size and set manual fee
// BEFORE calling addChangeIfNeeded, since CSL calculates fee based on body size only
// The witness set size (signatures) increases total transaction size, which increases fee
// This is especially important for voting registration transactions with CIP-36 metadata
if (
state.metadata.length > 0 &&
!state.options.manualFee &&
(!state.options.mints || state.options.mints.length === 0)
) {
// Estimate witness set size: each input needs a signature (~64 bytes)
// Plus CBOR overhead for witness set structure (~20 bytes)
const signatureSize = 64
const witnessSetOverhead = 20
const estimatedWitnessSetSize =
state.inputs.length * signatureSize + witnessSetOverhead

// Estimate transaction body size
const baseBodySizeEstimate = 200
const inputsSize = state.inputs.length * 80
const outputsSize = state.outputs.length * 150
const certificatesSize = state.certificates.length * 120
const withdrawalsSize = state.withdrawals.length * 60

// Metadata size - estimate conservatively based on actual metadata
let metadataSize = 100 // Base overhead
for (const meta of state.metadata) {
if (meta) {
const metaStr = JSON.stringify(meta.data)
// CIP-36 metadata with hex addresses can be large
metadataSize += Math.max(metaStr.length, 300)
}
}

// Estimate change output size (will be added by addChangeIfNeeded)
const changeOutputSize = 150

const estimatedBodySize =
baseBodySizeEstimate +
inputsSize +
outputsSize +
certificatesSize +
withdrawalsSize +
metadataSize +
changeOutputSize

// Apply safety multiplier for CBOR encoding overhead
const adjustedBodySize = Math.ceil(estimatedBodySize * 2.0)

// Total transaction size including witness set
const totalTxSizeEstimate = adjustedBodySize + estimatedWitnessSetSize

// Calculate fee with buffer: constant + coefficient * total_size
// Add 10% buffer to account for fee calculation variance
const baseFee =
BigInt(protocolParams.linearFee.constant) +
BigInt(protocolParams.linearFee.coefficient) *
BigInt(totalTxSizeEstimate)
const feeBuffer = baseFee / BigInt(10) // 10% buffer
const totalFee = baseFee + feeBuffer

// Set manual fee BEFORE calling addChangeIfNeeded
const feeBigNum = csl.BigNum.fromStr(totalFee.toString())
if (feeBigNum) {
cslTxBuilder.setFee(feeBigNum)
getLogger().info(
'buildTransaction: Set manual fee accounting for metadata and witness set',
{
estimatedBodySize,
adjustedBodySize,
estimatedWitnessSetSize,
totalTxSizeEstimate,
baseFee: baseFee.toString(),
feeBuffer: feeBuffer.toString(),
totalFee: totalFee.toString(),
metadataCount: state.metadata.length,
inputsCount: state.inputs.length,
},
)
}
}

// If minting with native scripts, calculate witness set size and estimate fee
// BEFORE calling addChangeIfNeeded, since CSL calculates fee based on body size only
// The witness set size increases total transaction size, which increases fee
Expand Down
22 changes: 15 additions & 7 deletions mobile/packages/tx/transaction-builder/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ describe('transaction builder helpers', () => {

expect(result.label).toBe(61284)
expect(result.data).toEqual({
1: 'voting_key',
2: 'staking_key',
3: 'reward_addr',
1: '0xvoting_key',
2: '0xstaking_key',
3: '0xreward_addr',
4: 123,
})
})
Expand All @@ -80,10 +80,11 @@ describe('transaction builder helpers', () => {

expect(result.label).toBe(61284)
expect(result.data).toEqual({
1: 'voting_key',
2: 'staking_key',
3: 'reward_addr',
1: [['0xvoting_key', 1]],
2: '0xstaking_key',
3: '0xreward_addr',
4: 123,
5: 0,
})
})

Expand All @@ -96,7 +97,14 @@ describe('transaction builder helpers', () => {
'payment_addr',
)

expect(result.data).toHaveProperty('5', 'payment_addr')
expect(result.label).toBe(61284)
expect(result.data).toEqual({
1: [['0xvoting_key', 1]],
2: '0xstaking_key',
3: '0xpayment_addr',
4: 123,
5: 0,
})
})
})

Expand Down
57 changes: 43 additions & 14 deletions mobile/packages/tx/transaction-builder/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,19 +446,36 @@ export function createCIP15VotingMetadata(
typeof stakingPublicKey === 'string' ? stakingPublicKey : stakingPublicKey
const rewardAddrStr =
typeof rewardAddress === 'string' ? rewardAddress : rewardAddress
// Add 0x prefix for hex strings to match Cardano metadata format
// Remove 0x if already present to avoid double prefix
const votingKeyHex = votingKeyStr.startsWith('0x')
? votingKeyStr
: `0x${votingKeyStr}`
const stakingKeyHex = stakingKeyStr.startsWith('0x')
? stakingKeyStr
: `0x${stakingKeyStr}`
const rewardAddrHex = rewardAddrStr.startsWith('0x')
? rewardAddrStr
: `0x${rewardAddrStr}`
return {
label: 61284, // CIP-15 DATA label
data: {
1: votingKeyStr,
2: stakingKeyStr,
3: rewardAddrStr,
1: votingKeyHex,
2: stakingKeyHex,
3: rewardAddrHex,
4: nonce,
},
}
}

/**
* Create CIP-36 voting metadata (new Catalyst voting format)
* CIP-36 format:
* - Field 1: delegations (array of [votingKey, weight])
* - Field 2: stake_credential (staking key)
* - Field 3: payment_address (payment address for receiving voting rewards)
* - Field 4: nonce
* - Field 5: voting_purpose (optional, default 0)
*/
export function createCIP36VotingMetadata(
votingPublicKey: PublicKeyHex | string,
Expand All @@ -471,19 +488,31 @@ export function createCIP36VotingMetadata(
typeof votingPublicKey === 'string' ? votingPublicKey : votingPublicKey
const stakingKeyStr =
typeof stakingPublicKey === 'string' ? stakingPublicKey : stakingPublicKey
const rewardAddrStr =
typeof rewardAddress === 'string' ? rewardAddress : rewardAddress
// Add 0x prefix for hex strings to match Cardano metadata format
// Remove 0x if already present to avoid double prefix
const votingKeyHex = votingKeyStr.startsWith('0x')
? votingKeyStr
: `0x${votingKeyStr}`
const stakingKeyHex = stakingKeyStr.startsWith('0x')
? stakingKeyStr
: `0x${stakingKeyStr}`

// CIP-36 uses payment_address in field 3, not reward_address
// If paymentAddress is provided, use it; otherwise fall back to rewardAddress
const addressToUse = paymentAddress || rewardAddress
const addressStr =
typeof addressToUse === 'string' ? addressToUse : addressToUse
const addressHex = addressStr.startsWith('0x')
? addressStr
: `0x${addressStr}`

// CIP-36 format: field 1 is an array of [votingKey, weight]
const metadata: Record<string, unknown> = {
1: votingKeyStr,
2: stakingKeyStr,
3: rewardAddrStr,
1: [[votingKeyHex, 1]],
2: stakingKeyHex,
3: addressHex, // payment_address (field 3 in CIP-36)
4: nonce,
}

if (paymentAddress) {
const paymentAddrStr =
typeof paymentAddress === 'string' ? paymentAddress : paymentAddress
metadata[5] = paymentAddrStr
5: 0, // voting_purpose (default 0 for voting registration)
}

return {
Expand Down
2 changes: 1 addition & 1 deletion mobile/packages/wallet-manager/wallet-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1172,7 +1172,7 @@ export const makeWalletManager = (
internal: [],
external: [firstAddress],
rewardAddressHex,
enableDiscovery: true, // Enable discovery to find more addresses
enableDiscovery: false, // Disable comprehensive discovery for full read-only wallets (they have accountPubKeyHex to derive addresses)
accountVisual,
})

Expand Down
Loading
Loading