Skip to content

Add 0-value P2A output to offchain transactions #566

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

Merged
merged 13 commits into from
May 21, 2025
7 changes: 2 additions & 5 deletions client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,14 @@ func dumpPrivKey(ctx *cli.Context) error {
}

func receive(ctx *cli.Context) error {
offchainAddr, boardingAddr, err := arkSdkClient.Receive(ctx.Context)
onchainAddr, offchainAddr, boardingAddr, err := arkSdkClient.Receive(ctx.Context)
if err != nil {
return err
}
return printJSON(map[string]interface{}{
"boarding_address": boardingAddr,
"offchain_address": offchainAddr,
"onchain_address": onchainAddr,
})
}

Expand Down Expand Up @@ -397,10 +398,6 @@ func redeem(ctx *cli.Context) error {
return arkSdkClient.StartUnilateralExit(ctx.Context)
}

if address == "" {
return fmt.Errorf("missing destination address")
}

if complete {
txID, err := arkSdkClient.CompleteUnilateralExit(ctx.Context, address)
if err != nil {
Expand Down
43 changes: 0 additions & 43 deletions common/fees.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,6 @@ import (
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)

var TreeTxSize = (&input.TxWeightEstimator{}).
AddTaprootKeySpendInput(txscript.SigHashDefault). // parent
AddP2TROutput(). // left child
AddP2TROutput(). // right child
VSize()

var ConnectorTxSize = (&input.TxWeightEstimator{}).
AddTaprootKeySpendInput(txscript.SigHashDefault).
AddP2TROutput().
AddP2TROutput().
AddP2TROutput().
AddP2TROutput().
VSize()

func ComputeForfeitTxFee(
feeRate chainfee.SatPerKVByte,
tapscript *waddrmgr.Tapscript,
Expand Down Expand Up @@ -56,32 +42,3 @@ func ComputeForfeitTxFee(

return uint64(feeRate.FeeForVSize(lntypes.VByte(txWeightEstimator.VSize())).ToUnit(btcutil.AmountSatoshi)), nil
}

func ComputeRedeemTxFee(
feeRate chainfee.SatPerKVByte,
vtxos []VtxoInput,
numOutputs int,
) (int64, error) {
if len(vtxos) <= 0 {
return 0, fmt.Errorf("missing vtxos")
}

redeemTxWeightEstimator := &input.TxWeightEstimator{}

// Estimate inputs
for _, vtxo := range vtxos {
if vtxo.Tapscript == nil {
txid := vtxo.Outpoint.Hash.String()
return 0, fmt.Errorf("missing tapscript for vtxo %s", txid)
}

redeemTxWeightEstimator.AddTapscriptInput(lntypes.WeightUnit(vtxo.WitnessSize), vtxo.Tapscript)
}

// Estimate outputs
for i := 0; i < numOutputs; i++ {
redeemTxWeightEstimator.AddP2TROutput()
}

return int64(feeRate.FeeForVSize(lntypes.VByte(redeemTxWeightEstimator.VSize())).ToUnit(btcutil.AmountSatoshi)), nil
}
66 changes: 66 additions & 0 deletions common/tree/anchor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package tree

import (
"bytes"

"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
)

var (
ANCHOR_PKSCRIPT = []byte{
0x51, 0x02, 0x4e, 0x73,
}
ANCHOR_VALUE = int64(0)
)

func AnchorOutput() *wire.TxOut {
return &wire.TxOut{
Value: ANCHOR_VALUE,
PkScript: ANCHOR_PKSCRIPT,
}
}

// ExtractWithAnchors extracts the final witness and scriptSig from psbt fields and ignores anchor inputs without failing.
func ExtractWithAnchors(p *psbt.Packet) (*wire.MsgTx, error) {
finalTx := p.UnsignedTx.Copy()

for i, tin := range finalTx.TxIn {
pInput := p.Inputs[i]

// ignore anchor outputs
if pInput.WitnessUtxo != nil && bytes.Equal(pInput.WitnessUtxo.PkScript, ANCHOR_PKSCRIPT) {
continue
}

if pInput.FinalScriptSig != nil {
tin.SignatureScript = pInput.FinalScriptSig
}

if pInput.FinalScriptWitness != nil {
witnessReader := bytes.NewReader(
pInput.FinalScriptWitness,
)

witCount, err := wire.ReadVarInt(witnessReader, 0)
if err != nil {
return nil, err
}

tin.Witness = make(wire.TxWitness, witCount)
for j := uint64(0); j < witCount; j++ {
wit, err := wire.ReadVarBytes(
witnessReader, 0,
txscript.MaxScriptSize, "witness",
)
if err != nil {
return nil, err
}
tin.Witness[j] = wit
}
}
}

return finalTx, nil
}
38 changes: 16 additions & 22 deletions common/tree/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,14 @@ const (
// radix is hardcoded to 2
func CraftSharedOutput(
receivers []Leaf,
feeSatsPerNode uint64,
sweepTapTreeRoot []byte,
) ([]byte, int64, error) {
root, err := createTxTree(receivers, feeSatsPerNode, sweepTapTreeRoot, vtxoTreeRadix)
root, err := createTxTree(receivers, sweepTapTreeRoot, vtxoTreeRadix)
if err != nil {
return nil, 0, err
}

amount := root.getAmount() + int64(feeSatsPerNode)
amount := root.getAmount() + ANCHOR_VALUE

aggregatedKey, err := AggregateKeys(root.getCosigners(), sweepTapTreeRoot)
if err != nil {
Expand All @@ -50,11 +49,10 @@ func CraftSharedOutput(
func BuildVtxoTree(
initialInput *wire.OutPoint,
receivers []Leaf,
feeSatsPerNode uint64,
sweepTapTreeRoot []byte,
vtxoTreeExpiry common.RelativeLocktime,
) (TxTree, error) {
root, err := createTxTree(receivers, feeSatsPerNode, sweepTapTreeRoot, vtxoTreeRadix)
root, err := createTxTree(receivers, sweepTapTreeRoot, vtxoTreeRadix)
if err != nil {
return nil, err
}
Expand All @@ -66,14 +64,13 @@ func BuildVtxoTree(
// radix is hardcoded to 4
func CraftConnectorsOutput(
receivers []Leaf,
feeSatsPerNode uint64,
) ([]byte, int64, error) {
root, err := createTxTree(receivers, feeSatsPerNode, nil, connectorsTreeRadix)
root, err := createTxTree(receivers, nil, connectorsTreeRadix)
if err != nil {
return nil, 0, err
}

amount := root.getAmount() + int64(feeSatsPerNode)
amount := root.getAmount() + ANCHOR_VALUE

aggregatedKey, err := AggregateKeys(root.getCosigners(), nil)
if err != nil {
Expand All @@ -93,9 +90,8 @@ func CraftConnectorsOutput(
func BuildConnectorsTree(
initialInput *wire.OutPoint,
receivers []Leaf,
feeSatsPerNode uint64,
) (TxTree, error) {
root, err := createTxTree(receivers, feeSatsPerNode, nil, connectorsTreeRadix)
root, err := createTxTree(receivers, nil, connectorsTreeRadix)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -150,7 +146,7 @@ func toTxTree(root node, initialInput *wire.OutPoint, expiry *common.RelativeLoc
}

type node interface {
getAmount() int64 // returns the input amount of the node = sum of all receivers' amounts + fees
getAmount() int64 // returns the input amount of the node = sum of all receivers' amounts
getOutputs() ([]*wire.TxOut, error)
getChildren() []node
getCosigners() []*secp256k1.PublicKey
Expand All @@ -167,7 +163,6 @@ type branch struct {
cosigners []*secp256k1.PublicKey
pkScript []byte
children []node
feeAmount int64
}

func (b *branch) getCosigners() []*secp256k1.PublicKey {
Expand All @@ -190,7 +185,7 @@ func (b *branch) getAmount() int64 {
amount := int64(0)
for _, child := range b.children {
amount += child.getAmount()
amount += b.feeAmount
amount += ANCHOR_VALUE
}

return amount
Expand All @@ -206,6 +201,7 @@ func (l *leaf) getOutputs() ([]*wire.TxOut, error) {
Value: l.amount,
PkScript: l.pkScript,
},
AnchorOutput(),
}, nil
}

Expand All @@ -214,12 +210,12 @@ func (b *branch) getOutputs() ([]*wire.TxOut, error) {

for _, child := range b.children {
outputs = append(outputs, &wire.TxOut{
Value: child.getAmount() + b.feeAmount,
Value: child.getAmount(),
PkScript: b.pkScript,
})
}

return outputs, nil
return append(outputs, AnchorOutput()), nil
}

func getTreeNode(
Expand Down Expand Up @@ -257,7 +253,7 @@ func getTx(
return nil, err
}

tx, err := psbt.New([]*wire.OutPoint{input}, outputs, 2, 0, []uint32{wire.MaxTxInSequenceNum})
tx, err := psbt.New([]*wire.OutPoint{input}, outputs, 3, 0, []uint32{wire.MaxTxInSequenceNum})
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -290,7 +286,6 @@ func getTx(
// from the leaves to the root.
func createTxTree(
receivers []Leaf,
feeSatsPerNode uint64,
tapTreeRoot []byte,
radix int,
) (root node, err error) {
Expand Down Expand Up @@ -372,7 +367,7 @@ func createTxTree(
}

for len(nodes) > 1 {
nodes, err = createUpperLevel(nodes, int64(feeSatsPerNode), tapTreeRoot, radix)
nodes, err = createUpperLevel(nodes, tapTreeRoot, radix)
if err != nil {
return nil, fmt.Errorf("failed to create tx tree: %w", err)
}
Expand All @@ -381,20 +376,20 @@ func createTxTree(
return nodes[0], nil
}

func createUpperLevel(nodes []node, feeAmount int64, tapTreeRoot []byte, radix int) ([]node, error) {
func createUpperLevel(nodes []node, tapTreeRoot []byte, radix int) ([]node, error) {
if len(nodes) <= 1 {
return nodes, nil
}

if len(nodes) < radix {
return createUpperLevel(nodes, feeAmount, tapTreeRoot, len(nodes))
return createUpperLevel(nodes, tapTreeRoot, len(nodes))
}

remainder := len(nodes) % radix
if remainder != 0 {
// Handle nodes that don't form a complete group
last := nodes[len(nodes)-remainder:]
groups, err := createUpperLevel(nodes[:len(nodes)-remainder], feeAmount, tapTreeRoot, radix)
groups, err := createUpperLevel(nodes[:len(nodes)-remainder], tapTreeRoot, radix)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -425,7 +420,6 @@ func createUpperLevel(nodes []node, feeAmount int64, tapTreeRoot []byte, radix i
branchNode := &branch{
pkScript: pkScript,
cosigners: cosigners,
feeAmount: feeAmount,
children: children,
}

Expand Down
9 changes: 2 additions & 7 deletions common/tree/musig2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@ import (
"github.com/stretchr/testify/require"
)

const (
minRelayFee = 1000
exitDelay = 512
)

var (
vtxoTreeExpiry = common.RelativeLocktime{Type: common.LocktimeTypeBlock, Value: 144}
rootInput, _ = wire.NewOutPointFromString("49f8664acc899be91902f8ade781b7eeb9cbe22bdd9efbc36e56195de21bcd12:0")
Expand All @@ -41,14 +36,14 @@ func TestBuildAndSignVtxoTree(t *testing.T) {
for _, v := range testVectors {
t.Run(v.name, func(t *testing.T) {
sharedOutScript, sharedOutAmount, err := tree.CraftSharedOutput(
v.receivers, minRelayFee, sweepRoot[:],
v.receivers, sweepRoot[:],
)
require.NoError(t, err)
require.NotNil(t, sharedOutScript)
require.NotZero(t, sharedOutAmount)

vtxoTree, err := tree.BuildVtxoTree(
rootInput, v.receivers, minRelayFee, sweepRoot[:], vtxoTreeExpiry,
rootInput, v.receivers, sweepRoot[:], vtxoTreeExpiry,
)
require.NoError(t, err)
require.NotNil(t, vtxoTree)
Expand Down
5 changes: 4 additions & 1 deletion common/tree/pending.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const (
cltvSequence = wire.MaxTxInSequenceNum - 1
)

// BuildRedeemTx builds a redeem tx for the given vtxos and outputs.
// The redeem tx is spending VTXOs using collaborative taproot path.
// An anchor output is added to the transaction
func BuildRedeemTx(
vtxos []common.VtxoInput,
outputs []*wire.TxOut,
Expand Down Expand Up @@ -95,7 +98,7 @@ func BuildRedeemTx(
}

redeemPtx, err := psbt.New(
ins, outputs, 2, uint32(txLocktime), sequences,
ins, append(outputs, AnchorOutput()), 3, uint32(txLocktime), sequences,
)
if err != nil {
return "", err
Expand Down
4 changes: 2 additions & 2 deletions common/tree/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func ValidateVtxoTree(
sumRootValue += output.Value
}

if sumRootValue >= roundTxAmount {
if sumRootValue != roundTxAmount {
return ErrInvalidAmount
}

Expand Down Expand Up @@ -220,7 +220,7 @@ func validateNodeTransaction(node Node, tree TxTree, tapTreeRoot []byte) error {
sumChildAmount += output.Value
}

if sumChildAmount >= parentOutput.Value {
if sumChildAmount != parentOutput.Value {
return ErrInvalidAmount
}
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/client-sdk/ark_sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type ArkClient interface {
Unlock(ctx context.Context, password string) error
Lock(ctx context.Context) error
Balance(ctx context.Context, computeExpiryDetails bool) (*Balance, error)
Receive(ctx context.Context) (offchainAddr, boardingAddr string, err error)
Receive(ctx context.Context) (onchainAddr, offchainAddr, boardingAddr string, err error)
SendOffChain(
ctx context.Context, withExpiryCoinselect bool, receivers []Receiver,
withZeroFees bool,
Expand Down
Loading
Loading