diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index 19d54532b..c50e01537 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -1216,8 +1216,14 @@ type InitWalletResponse struct { // recovered_oor_events is the number of OOR recipient events processed // during recovery. RecoveredOorEvents uint32 `protobuf:"varint,7,opt,name=recovered_oor_events,json=recoveredOorEvents,proto3" json:"recovered_oor_events,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // recovered_vhtlcs is the number of indexed vHTLC recovery manifests + // matched during recovery. + RecoveredVhtlcs uint32 `protobuf:"varint,8,opt,name=recovered_vhtlcs,json=recoveredVhtlcs,proto3" json:"recovered_vhtlcs,omitempty"` + // recovered_vhtlc_refunds is the number of matched pay-side vHTLCs for + // which daemon-owned refund recovery is available. + RecoveredVhtlcRefunds uint32 `protobuf:"varint,9,opt,name=recovered_vhtlc_refunds,json=recoveredVhtlcRefunds,proto3" json:"recovered_vhtlc_refunds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *InitWalletResponse) Reset() { @@ -1299,6 +1305,20 @@ func (x *InitWalletResponse) GetRecoveredOorEvents() uint32 { return 0 } +func (x *InitWalletResponse) GetRecoveredVhtlcs() uint32 { + if x != nil { + return x.RecoveredVhtlcs + } + return 0 +} + +func (x *InitWalletResponse) GetRecoveredVhtlcRefunds() uint32 { + if x != nil { + return x.RecoveredVhtlcRefunds + } + return 0 +} + type UnlockWalletRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // wallet_password is the password used to decrypt the seed stored on @@ -2666,6 +2686,12 @@ type Output struct { // created output when the caller knows semantic ownership metadata // that is not already carried by the destination itself. VtxoPolicyTemplate []byte `protobuf:"bytes,5,opt,name=vtxo_policy_template,json=vtxoPolicyTemplate,proto3" json:"vtxo_policy_template,omitempty"` + // receive_script_label, when set on an OOR output, asks the daemon to + // register the resolved recipient pk_script with the indexer before + // submitting the transfer. Swap recovery uses this to persist compact + // vHTLC restore metadata before later local swap DB state becomes + // required. + ReceiveScriptLabel string `protobuf:"bytes,6,opt,name=receive_script_label,json=receiveScriptLabel,proto3" json:"receive_script_label,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2748,6 +2774,13 @@ func (x *Output) GetVtxoPolicyTemplate() []byte { return nil } +func (x *Output) GetReceiveScriptLabel() string { + if x != nil { + return x.ReceiveScriptLabel + } + return "" +} + type isOutput_Destination interface { isOutput_Destination() } @@ -7934,7 +7967,7 @@ const file_daemon_proto_rawDesc = "" + "\x0fwallet_password\x18\x02 \x01(\fR\x0ewalletPassword\x12'\n" + "\x0fseed_passphrase\x18\x03 \x01(\fR\x0eseedPassphrase\x12#\n" + "\rrecover_state\x18\x04 \x01(\bR\frecoverState\x12'\n" + - "\x0frecovery_window\x18\x05 \x01(\rR\x0erecoveryWindow\"\xfa\x02\n" + + "\x0frecovery_window\x18\x05 \x01(\rR\x0erecoveryWindow\"\xdd\x03\n" + "\x12InitWalletResponse\x12'\n" + "\x0fidentity_pubkey\x18\x01 \x01(\tR\x0eidentityPubkey\x12!\n" + "\frecovery_ran\x18\x02 \x01(\bR\vrecoveryRan\x12@\n" + @@ -7942,7 +7975,9 @@ const file_daemon_proto_rawDesc = "" + "\x18recovered_boarding_utxos\x18\x04 \x01(\rR\x16recoveredBoardingUtxos\x12'\n" + "\x0frecovered_vtxos\x18\x05 \x01(\rR\x0erecoveredVtxos\x12A\n" + "\x1drecovered_oor_receive_scripts\x18\x06 \x01(\rR\x1arecoveredOorReceiveScripts\x120\n" + - "\x14recovered_oor_events\x18\a \x01(\rR\x12recoveredOorEvents\">\n" + + "\x14recovered_oor_events\x18\a \x01(\rR\x12recoveredOorEvents\x12)\n" + + "\x10recovered_vhtlcs\x18\b \x01(\rR\x0frecoveredVhtlcs\x126\n" + + "\x17recovered_vhtlc_refunds\x18\t \x01(\rR\x15recoveredVhtlcRefunds\">\n" + "\x13UnlockWalletRequest\x12'\n" + "\x0fwallet_password\x18\x01 \x01(\fR\x0ewalletPassword\"?\n" + "\x14UnlockWalletResponse\x12'\n" + @@ -8024,14 +8059,15 @@ const file_daemon_proto_rawDesc = "" + "\fsession_txid\x18\x02 \x01(\fR\vsessionTxid\"j\n" + "\"GetIndexedOORSessionByTxidResponse\x12\x19\n" + "\bark_psbt\x18\x01 \x01(\fR\aarkPsbt\x12)\n" + - "\x10checkpoint_psbts\x18\x02 \x03(\fR\x0fcheckpointPsbts\"\xc9\x01\n" + + "\x10checkpoint_psbts\x18\x02 \x03(\fR\x0fcheckpointPsbts\"\xfb\x01\n" + "\x06Output\x12\x1a\n" + "\aaddress\x18\x01 \x01(\tH\x00R\aaddress\x12\x18\n" + "\x06pubkey\x18\x03 \x01(\fH\x00R\x06pubkey\x12)\n" + "\x0fpolicy_template\x18\x04 \x01(\fH\x00R\x0epolicyTemplate\x12\x1d\n" + "\n" + "amount_sat\x18\x02 \x01(\x03R\tamountSat\x120\n" + - "\x14vtxo_policy_template\x18\x05 \x01(\fR\x12vtxoPolicyTemplateB\r\n" + + "\x14vtxo_policy_template\x18\x05 \x01(\fR\x12vtxoPolicyTemplate\x120\n" + + "\x14receive_script_label\x18\x06 \x01(\tR\x12receiveScriptLabelB\r\n" + "\vdestination\"]\n" + "\x0fSendVTXORequest\x121\n" + "\n" + diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index 3844bd0d0..2ec14ab12 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -417,6 +417,14 @@ message InitWalletResponse { // recovered_oor_events is the number of OOR recipient events processed // during recovery. uint32 recovered_oor_events = 7; + + // recovered_vhtlcs is the number of indexed vHTLC recovery manifests + // matched during recovery. + uint32 recovered_vhtlcs = 8; + + // recovered_vhtlc_refunds is the number of matched pay-side vHTLCs for + // which daemon-owned refund recovery is available. + uint32 recovered_vhtlc_refunds = 9; } message UnlockWalletRequest { @@ -746,6 +754,13 @@ message Output { // created output when the caller knows semantic ownership metadata // that is not already carried by the destination itself. bytes vtxo_policy_template = 5; + + // receive_script_label, when set on an OOR output, asks the daemon to + // register the resolved recipient pk_script with the indexer before + // submitting the transfer. Swap recovery uses this to persist compact + // vHTLC restore metadata before later local swap DB state becomes + // required. + string receive_script_label = 6; } message SendVTXORequest { diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index 8f8feb3f1..c5953e0a9 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -75,6 +75,11 @@ const ( // RPC handler resolves scripts and policy templates before handing // work to that actor, so it needs its own cheap request-size guard. maxOORRecipients = 256 + + // vhtlcRecoveryManifestRegistrationTTL keeps swap recovery manifests + // available across realistic offline restore windows. The signed + // registration proof itself remains short lived in the indexer client. + vhtlcRecoveryManifestRegistrationTTL = 365 * 24 * time.Hour ) // RPCServer implements the daemon's gRPC DaemonService interface. @@ -460,6 +465,47 @@ func (r *RPCServer) buildSendOORRecipients(ctx context.Context, return recipients, nil } +// registerSendOORRecipientScripts stores caller-requested script labels with +// the indexer after recipient scripts are resolved but before OOR submission. +func (r *RPCServer) registerSendOORRecipientScripts(ctx context.Context, + requestRecipients []*daemonrpc.Output, + oorRecipients []oortx.RecipientOutput) error { + + if len(requestRecipients) != len(oorRecipients) { + return status.Errorf(codes.Internal, "recipient count mismatch") + } + + for i := range requestRecipients { + label := requestRecipients[i].GetReceiveScriptLabel() + if label == "" { + continue + } + + if r.server.indexer == nil { + return status.Errorf(codes.Internal, "indexer client "+ + "not initialized") + } + + idx := r.server.indexer.WithSigner( + r.server.proofKeyBackend.ProofSigner( + r.server.clientKeyDesc, + ), + ) + expiresAt := r.server.clk.Now().Add( + vhtlcRecoveryManifestRegistrationTTL, + ) + _, err := idx.RegisterReceiveScriptTaproot( + ctx, oorRecipients[i].PkScript, expiresAt, label, + ) + if err != nil && status.Code(err) != codes.AlreadyExists { + return status.Errorf(codes.Internal, "register "+ + "OOR recipient script %d: %v", i, err) + } + } + + return nil +} + // resolveOORRecipientOutpoints maps each requested recipient to the outpoint it // occupies after the canonical OOR output ordering is applied. func (r *RPCServer) resolveOORRecipientOutpoints(ctx context.Context, @@ -2207,6 +2253,12 @@ func (r *RPCServer) SendOOR(ctx context.Context, }, nil } + if err := r.registerSendOORRecipientScripts( + ctx, requestRecipients, oorRecipients, + ); err != nil { + return nil, err + } + if r.server.actorSystem == nil { return nil, status.Errorf(codes.Internal, "actor system not "+ "initialized") diff --git a/darepod/wallet_recovery.go b/darepod/wallet_recovery.go index 8cbf7716b..7b2bef131 100644 --- a/darepod/wallet_recovery.go +++ b/darepod/wallet_recovery.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "math" "time" "github.com/btcsuite/btcd/btcec/v2" @@ -23,10 +24,13 @@ import ( "github.com/lightninglabs/darepo-client/lib/arkscript" libtypes "github.com/lightninglabs/darepo-client/lib/types" "github.com/lightninglabs/darepo-client/oor" + "github.com/lightninglabs/darepo-client/vhtlcrecovery" + "github.com/lightninglabs/darepo-client/vhtlcrecovery/coordinator" "github.com/lightninglabs/darepo-client/vtxo" "github.com/lightninglabs/darepo-client/wallet" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -45,6 +49,8 @@ type walletRecoveryResult struct { VTXOs uint32 OORReceiveScripts uint32 OOREvents uint32 + VHTLCs uint32 + VHTLCRefunds uint32 } // retryRecoveryIndexerRPC retries recovery-local indexer calls that hit the @@ -84,6 +90,8 @@ func (r walletRecoveryResult) apply(resp *daemonrpc.InitWalletResponse) { resp.RecoveredVtxos = r.VTXOs resp.RecoveredOorReceiveScripts = r.OORReceiveScripts resp.RecoveredOorEvents = r.OOREvents + resp.RecoveredVhtlcs = r.VHTLCs + resp.RecoveredVhtlcRefunds = r.VHTLCRefunds } func (r *RPCServer) recoverWalletState(ctx context.Context, window uint32) ( @@ -135,6 +143,9 @@ func (r *RPCServer) recoverWalletState(ctx context.Context, window uint32) ( ); err != nil { return nil, fmt.Errorf("recover OOR receive scripts: %w", err) } + if err := r.recoverIndexedVHTLCs(ctx, terms, &result); err != nil { + return nil, fmt.Errorf("recover indexed vHTLCs: %w", err) + } return &result, nil } @@ -605,6 +616,331 @@ func (r *RPCServer) recoverOORReceiveScripts(ctx context.Context, return nil } +// recoverIndexedVHTLCs finds registered vHTLC recovery manifests and arms +// daemon-owned refund recovery for live pay-side vHTLCs. +func (r *RPCServer) recoverIndexedVHTLCs(ctx context.Context, + terms *libtypes.OperatorTerms, result *walletRecoveryResult) error { + + registered, err := r.server.indexer.ListMyReceiveScripts(ctx) + if err != nil { + return fmt.Errorf("list registered receive scripts: %w", err) + } + + idx := r.server.indexer.WithSigner( + r.server.proofKeyBackend.ProofSigner( + r.server.clientKeyDesc, + ), + ) + + for _, script := range registered.GetScripts() { + manifest, ok, err := vhtlcrecovery.DecodeManifestLabel( + script.GetLabel(), + ) + if err != nil { + return err + } + if !ok || !recoverableVHTLCManifest(manifest) { + continue + } + + pkScript, err := validateVHTLCManifestScript(manifest) + if err != nil { + return err + } + + vtxos, err := r.listVHTLCManifestVTXOs(ctx, idx, pkScript) + if err != nil { + return err + } + if len(vtxos) == 0 { + continue + } + + result.VHTLCs++ + for _, indexed := range vtxos { + live := arkrpc.VTXOStatus_VTXO_STATUS_LIVE + if indexed.GetStatus() != live { + continue + } + + recovered, err := r.armRecoveredVHTLCRefund( + ctx, terms, manifest, indexed, + ) + if err != nil { + return err + } + if recovered { + result.VHTLCRefunds++ + } + + break + } + } + + return nil +} + +// recoverableVHTLCManifest limits v1 seed restore to pay-side sender refunds. +func recoverableVHTLCManifest( + manifest vhtlcrecovery.RecoveryManifest) bool { + + return manifest.Role == vhtlcrecovery.ManifestRoleSender && + manifest.Direction == vhtlcrecovery.ManifestDirectionPay +} + +// validateVHTLCManifestScript rebuilds the vHTLC policy and checks that the +// manifest really describes the script it asks recovery to query. +func validateVHTLCManifestScript( + manifest vhtlcrecovery.RecoveryManifest) ([]byte, error) { + + policy, err := vhtlcPolicyFromManifest(manifest) + if err != nil { + return nil, err + } + + derivedScript, err := policy.PkScript() + if err != nil { + return nil, fmt.Errorf("derive vHTLC manifest script: %w", + err) + } + if !bytes.Equal(derivedScript, manifest.PkScript) { + return nil, fmt.Errorf("vHTLC recovery manifest does not " + + "match script") + } + + return derivedScript, nil +} + +// vhtlcPolicyFromManifest converts manifest fields into the script policy used +// by the recovery executor. +func vhtlcPolicyFromManifest( + manifest vhtlcrecovery.RecoveryManifest) (*arkscript.VHTLCPolicy, + error) { + + sender, err := btcec.ParsePubKey(manifest.SenderPubkey) + if err != nil { + return nil, fmt.Errorf("parse sender pubkey: %w", err) + } + receiver, err := btcec.ParsePubKey(manifest.ReceiverPubkey) + if err != nil { + return nil, fmt.Errorf("parse receiver pubkey: %w", err) + } + server, err := btcec.ParsePubKey(manifest.ServerPubkey) + if err != nil { + return nil, fmt.Errorf("parse server pubkey: %w", err) + } + + var paymentHash lntypes.Hash + copy(paymentHash[:], manifest.PaymentHash) + + opts := arkscript.VHTLCOpts{ + Sender: sender, + Receiver: receiver, + Server: server, + PreimageHash: paymentHash, + RefundLocktime: manifest.RefundLocktime, + } + opts.UnilateralClaimDelay = manifest.UnilateralClaimDelay + opts.UnilateralRefundDelay = manifest.UnilateralRefundDelay + opts.UnilateralRefundWithoutReceiverDelay = + manifest.UnilateralRefundWithoutReceiverDelay + + return arkscript.NewVHTLCPolicy(opts) +} + +// listVHTLCManifestVTXOs queries the indexer for all known states of one +// manifest-backed vHTLC script. +func (r *RPCServer) listVHTLCManifestVTXOs(ctx context.Context, + idx *indexer.Client, pkScript []byte) ([]*arkrpc.VTXO, error) { + + statusFilter := []arkrpc.VTXOStatus{ + arkrpc.VTXOStatus_VTXO_STATUS_UNCONFIRMED, + arkrpc.VTXOStatus_VTXO_STATUS_LIVE, + arkrpc.VTXOStatus_VTXO_STATUS_FORFEITING, + arkrpc.VTXOStatus_VTXO_STATUS_FORFEITED, + arkrpc.VTXOStatus_VTXO_STATUS_SPENT, + } + + var ( + cursor []byte + vtxos []*arkrpc.VTXO + ) + for { + var resp *arkrpc.ListVTXOsByScriptsResponse + err := retryRecoveryIndexerRPC(ctx, func() error { + var err error + resp, err = idx.ListVTXOsByScriptsTaproot( + ctx, + []indexer.TaprootScriptScope{{ + PkScript: pkScript, + }}, + cursor, recoveryVTXOPageSize, statusFilter, + ) + + return err + }) + if err != nil { + return nil, fmt.Errorf("list vHTLC manifest VTXOs: %w", + err) + } + + for _, indexed := range vtxo.FlattenListVTXOsByScriptsResponse( + resp, + ) { + if bytes.Equal(indexed.GetPkScript(), pkScript) { + vtxos = append(vtxos, indexed) + } + } + + cursor = resp.GetNextCursor() + if len(cursor) == 0 { + break + } + } + + return vtxos, nil +} + +// armRecoveredVHTLCRefund creates the same dormant refund-without-receiver row +// that a normal pay session would have armed after observing funding. +func (r *RPCServer) armRecoveredVHTLCRefund(ctx context.Context, + terms *libtypes.OperatorTerms, manifest vhtlcrecovery.RecoveryManifest, + indexed *arkrpc.VTXO) (bool, error) { + + service, err := r.requireVHTLCRecovery() + if err != nil { + return false, err + } + + requestID := recoveredVHTLCRequestID(manifest) + exists, err := r.vhtlcRecoveryRequestExists(ctx, service, requestID) + if err != nil { + return false, err + } + if exists { + return true, nil + } + + outpoint, err := recoveryOutpoint(indexed.GetOutpoint()) + if err != nil { + return false, err + } + if indexed.GetValueSat() > math.MaxInt64 { + return false, fmt.Errorf("vHTLC amount exceeds int64") + } + + destinationScript, err := r.allocateVHTLCRecoveryRefundScript( + ctx, terms, + ) + if err != nil { + return false, err + } + + _, _, err = service.ArmRecovery(ctx, vhtlcrecovery.RecoveryJob{ + RequestID: requestID, + SwapID: append([]byte(nil), manifest.PaymentHash...), + Direction: vhtlcrecovery.DirectionPay, + Action: vhtlcrecovery.ActionRefundWithoutReceiver, + VTXOOutpoint: outpoint, + VTXOAmountSat: int64(indexed.GetValueSat()), + SenderPubkey: append( + []byte(nil), manifest.SenderPubkey..., + ), + ReceiverPubkey: append( + []byte(nil), manifest.ReceiverPubkey..., + ), + ServerPubkey: append( + []byte(nil), manifest.ServerPubkey..., + ), + RefundLocktime: int32(manifest.RefundLocktime), + UnilateralClaimDelay: int32( + manifest.UnilateralClaimDelay, + ), + UnilateralRefundDelay: int32( + manifest.UnilateralRefundDelay, + ), + UnilateralRefundWithoutReceiverDelay: int32( + manifest.UnilateralRefundWithoutReceiverDelay, + ), + PreimageHash: append([]byte(nil), manifest.PaymentHash...), + SignerKeyFamily: manifest.SignerKeyFamily, + SignerKeyIndex: manifest.SignerKeyIndex, + DestinationScript: append( + []byte(nil), destinationScript..., + ), + MaxFeeRateSatPerKWeight: DefaultSwapRecoveryMaxFeeRateSatPerKW, + }) + if err != nil { + return false, err + } + + return true, nil +} + +// vhtlcRecoveryRequestExists checks deterministic SDK request ids before +// allocating a new refund destination for an already-recovered vHTLC. +func (r *RPCServer) vhtlcRecoveryRequestExists(ctx context.Context, + service *coordinator.Service, requestID string) (bool, error) { + + statuses, err := service.ListRecoveryStatuses(ctx) + if err != nil { + return false, err + } + + for i := range statuses { + if statuses[i].Job.RequestID == requestID { + return true, nil + } + } + + return false, nil +} + +// allocateVHTLCRecoveryRefundScript creates the wallet-owned destination used +// by unilateral refund recovery after seed restore. +func (r *RPCServer) allocateVHTLCRecoveryRefundScript(ctx context.Context, + terms *libtypes.OperatorTerms) ([]byte, error) { + + store, err := r.newOORReceiveScriptStore() + if err != nil { + return nil, err + } + + deriveNextKey, signerFactory, err := r.oorReceiveKeyOps() + if err != nil { + return nil, err + } + + var pkScript []byte + err = retryRecoveryIndexerRPC(ctx, func() error { + var err error + _, pkScript, err = CreateOORReceiveScript( + ctx, r.server.indexer, store, deriveNextKey, + signerFactory, terms.PubKey, terms.VTXOExitDelay, + "vhtlc recovery refund", + ) + + return err + }) + if err != nil { + return nil, err + } + + return pkScript, nil +} + +// recoveredVHTLCRequestID matches the SDK's pay-side recovery idempotency key. +func recoveredVHTLCRequestID( + manifest vhtlcrecovery.RecoveryManifest) string { + + return fmt.Sprintf( + "sdk-swaps:%s:%x:%s", + manifest.Direction, + manifest.PaymentHash, + daemonrpc.VHTLCRecoveryAction_VHTLC_RECOVERY_ACTION_REFUND_WITHOUT_RECEIVER.String(), //nolint:ll + ) +} + func (r *RPCServer) recoveryOORHandler( terms *libtypes.OperatorTerms, packageStore *db.OORArtifactPersistenceStore, diff --git a/darepod/wallet_recovery_test.go b/darepod/wallet_recovery_test.go index 833b2a97a..6c6b8c1d6 100644 --- a/darepod/wallet_recovery_test.go +++ b/darepod/wallet_recovery_test.go @@ -4,6 +4,11 @@ import ( "context" "testing" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/vhtlcrecovery" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -46,3 +51,112 @@ func TestRetryRecoveryIndexerRPCStopsOnContextCancel(t *testing.T) { require.ErrorIs(t, err, context.Canceled) require.Equal(t, 1, attempts) } + +// TestRecoverableVHTLCManifestFiltersPaySender verifies v1 restore ignores +// receiver/out-swap manifests and only considers pay-side sender refunds. +func TestRecoverableVHTLCManifestFiltersPaySender(t *testing.T) { + t.Parallel() + + manifest, _ := testRecoveryManifest(t) + require.True(t, recoverableVHTLCManifest(manifest)) + + manifest.Role = vhtlcrecovery.ManifestRoleReceiver + require.False(t, recoverableVHTLCManifest(manifest)) + + manifest.Role = vhtlcrecovery.ManifestRoleSender + manifest.Direction = "receive" + require.False(t, recoverableVHTLCManifest(manifest)) +} + +// TestValidateVHTLCManifestScript verifies manifest metadata must rebuild the +// same script that recovery will query on the indexer. +func TestValidateVHTLCManifestScript(t *testing.T) { + t.Parallel() + + manifest, _ := testRecoveryManifest(t) + pkScript, err := validateVHTLCManifestScript(manifest) + require.NoError(t, err) + require.Equal(t, manifest.PkScript, pkScript) + + manifest.PkScript[len(manifest.PkScript)-1] ^= 0x01 + require.ErrorContains( + t, func() error { + _, err := validateVHTLCManifestScript(manifest) + + return err + }(), + "does not match", + ) +} + +// TestRecoveredVHTLCRequestIDMatchesSDKShape keeps restore idempotent with rows +// normally armed by the swap SDK. +func TestRecoveredVHTLCRequestIDMatchesSDKShape(t *testing.T) { + t.Parallel() + + manifest, _ := testRecoveryManifest(t) + require.Equal( + t, + "sdk-swaps:pay:0101010101010101010101010101010101010101010101010101010101010101:VHTLC_RECOVERY_ACTION_REFUND_WITHOUT_RECEIVER", //nolint:ll + recoveredVHTLCRequestID(manifest), + ) +} + +// testRecoveryManifest builds a self-consistent pay-side vHTLC manifest. +func testRecoveryManifest(t *testing.T) ( + vhtlcrecovery.RecoveryManifest, []byte) { + + t.Helper() + + sender, err := btcec.NewPrivateKey() + require.NoError(t, err) + receiver, err := btcec.NewPrivateKey() + require.NoError(t, err) + server, err := btcec.NewPrivateKey() + require.NoError(t, err) + + var paymentHash lntypes.Hash + for i := range paymentHash { + paymentHash[i] = 1 + } + + policy, err := arkscript.NewVHTLCPolicy(arkscript.VHTLCOpts{ + Sender: sender.PubKey(), + Receiver: receiver.PubKey(), + Server: server.PubKey(), + PreimageHash: paymentHash, + RefundLocktime: 144, + UnilateralClaimDelay: 12, + UnilateralRefundDelay: 24, + UnilateralRefundWithoutReceiverDelay: 36, + }) + require.NoError(t, err) + + pkScript, err := policy.PkScript() + require.NoError(t, err) + + pkScriptCopy := append([]byte(nil), pkScript...) + + return vhtlcrecovery.RecoveryManifest{ + Role: vhtlcrecovery.ManifestRoleSender, + Direction: vhtlcrecovery.ManifestDirectionPay, + PaymentHash: append([]byte(nil), paymentHash[:]...), + SenderPubkey: sender.PubKey(). + SerializeCompressed(), + ReceiverPubkey: receiver.PubKey(). + SerializeCompressed(), + ServerPubkey: server.PubKey(). + SerializeCompressed(), + RefundLocktime: 144, + UnilateralClaimDelay: 12, + UnilateralRefundDelay: 24, + UnilateralRefundWithoutReceiverDelay: 36, + PkScript: pkScriptCopy, + AmountSat: 42_000, + SignerKeyFamily: int32( + keychain.KeyFamilyNodeKey, + ), + SignerKeyIndex: 0, + StatusHint: "unsent_in_swap", + }, pkScript +} diff --git a/rpc/walletdkrpc/wallet.pb.go b/rpc/walletdkrpc/wallet.pb.go index 360ada3b8..8cfcbeef6 100644 --- a/rpc/walletdkrpc/wallet.pb.go +++ b/rpc/walletdkrpc/wallet.pb.go @@ -648,8 +648,14 @@ type CreateResponse struct { // recovered_oor_events is the number of OOR recipient events processed // during recovery. RecoveredOorEvents uint32 `protobuf:"varint,8,opt,name=recovered_oor_events,json=recoveredOorEvents,proto3" json:"recovered_oor_events,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // recovered_vhtlcs is the number of indexed vHTLC recovery manifests + // matched during recovery. + RecoveredVhtlcs uint32 `protobuf:"varint,9,opt,name=recovered_vhtlcs,json=recoveredVhtlcs,proto3" json:"recovered_vhtlcs,omitempty"` + // recovered_vhtlc_refunds is the number of matched pay-side vHTLCs for + // which daemon-owned refund recovery is available. + RecoveredVhtlcRefunds uint32 `protobuf:"varint,10,opt,name=recovered_vhtlc_refunds,json=recoveredVhtlcRefunds,proto3" json:"recovered_vhtlc_refunds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateResponse) Reset() { @@ -738,6 +744,20 @@ func (x *CreateResponse) GetRecoveredOorEvents() uint32 { return 0 } +func (x *CreateResponse) GetRecoveredVhtlcs() uint32 { + if x != nil { + return x.RecoveredVhtlcs + } + return 0 +} + +func (x *CreateResponse) GetRecoveredVhtlcRefunds() uint32 { + if x != nil { + return x.RecoveredVhtlcRefunds + } + return 0 +} + type UnlockRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // wallet_password is the password used to decrypt the on-disk seed. @@ -3795,7 +3815,7 @@ const file_wallet_proto_rawDesc = "" + "\x0fseed_passphrase\x18\x02 \x01(\fR\x0eseedPassphrase\x12\x1a\n" + "\bmnemonic\x18\x03 \x03(\tR\bmnemonic\x12#\n" + "\rrecover_state\x18\x04 \x01(\bR\frecoverState\x12'\n" + - "\x0frecovery_window\x18\x05 \x01(\rR\x0erecoveryWindow\"\x92\x03\n" + + "\x0frecovery_window\x18\x05 \x01(\rR\x0erecoveryWindow\"\xf5\x03\n" + "\x0eCreateResponse\x12\x1a\n" + "\bmnemonic\x18\x01 \x03(\tR\bmnemonic\x12'\n" + "\x0fidentity_pubkey\x18\x02 \x01(\tR\x0eidentityPubkey\x12!\n" + @@ -3804,7 +3824,10 @@ const file_wallet_proto_rawDesc = "" + "\x18recovered_boarding_utxos\x18\x05 \x01(\rR\x16recoveredBoardingUtxos\x12'\n" + "\x0frecovered_vtxos\x18\x06 \x01(\rR\x0erecoveredVtxos\x12A\n" + "\x1drecovered_oor_receive_scripts\x18\a \x01(\rR\x1arecoveredOorReceiveScripts\x120\n" + - "\x14recovered_oor_events\x18\b \x01(\rR\x12recoveredOorEvents\"8\n" + + "\x14recovered_oor_events\x18\b \x01(\rR\x12recoveredOorEvents\x12)\n" + + "\x10recovered_vhtlcs\x18\t \x01(\rR\x0frecoveredVhtlcs\x126\n" + + "\x17recovered_vhtlc_refunds\x18\n" + + " \x01(\rR\x15recoveredVhtlcRefunds\"8\n" + "\rUnlockRequest\x12'\n" + "\x0fwallet_password\x18\x01 \x01(\fR\x0ewalletPassword\"9\n" + "\x0eUnlockResponse\x12'\n" + diff --git a/rpc/walletdkrpc/wallet.proto b/rpc/walletdkrpc/wallet.proto index fc65f8402..f957e8cef 100644 --- a/rpc/walletdkrpc/wallet.proto +++ b/rpc/walletdkrpc/wallet.proto @@ -232,6 +232,14 @@ message CreateResponse { // recovered_oor_events is the number of OOR recipient events processed // during recovery. uint32 recovered_oor_events = 8; + + // recovered_vhtlcs is the number of indexed vHTLC recovery manifests + // matched during recovery. + uint32 recovered_vhtlcs = 9; + + // recovered_vhtlc_refunds is the number of matched pay-side vHTLCs for + // which daemon-owned refund recovery is available. + uint32 recovered_vhtlc_refunds = 10; } message UnlockRequest { diff --git a/sdk/ark/client.go b/sdk/ark/client.go index c1635e7ed..784feae4f 100644 --- a/sdk/ark/client.go +++ b/sdk/ark/client.go @@ -888,11 +888,24 @@ func (c *Client) SendOORWithPolicyAndKey(ctx context.Context, amountSat int64, func (c *Client) SendOORWithPolicyDetails(ctx context.Context, amountSat int64, recipientPolicyTemplate []byte) (*OORSendResult, error) { - return c.SendOORWithPolicyAndKeyDetails( + return c.SendOORWithPolicyDetailsAndLabel( ctx, amountSat, recipientPolicyTemplate, "", ) } +// SendOORWithPolicyDetailsAndLabel sends one OOR transfer to a semantic +// policy-backed destination, registers the output script with an indexer label, +// and returns the accepted OOR metadata. +func (c *Client) SendOORWithPolicyDetailsAndLabel(ctx context.Context, + amountSat int64, recipientPolicyTemplate []byte, + receiveScriptLabel string) (*OORSendResult, error) { + + return c.SendOORWithPolicyAndKeyDetailsLabel( + ctx, amountSat, recipientPolicyTemplate, "", + receiveScriptLabel, + ) +} + // SendOORWithPolicyAndKeyDetails sends one OOR transfer to a semantic // policy-backed destination using the supplied idempotency key and returns the // accepted OOR metadata. @@ -900,6 +913,19 @@ func (c *Client) SendOORWithPolicyAndKeyDetails(ctx context.Context, amountSat int64, recipientPolicyTemplate []byte, idempotencyKey string) (*OORSendResult, error) { + return c.SendOORWithPolicyAndKeyDetailsLabel( + ctx, amountSat, recipientPolicyTemplate, idempotencyKey, "", + ) +} + +// SendOORWithPolicyAndKeyDetailsLabel sends one OOR transfer to a semantic +// policy-backed destination using the supplied idempotency key and receive +// script registration label, then returns the accepted OOR metadata. +func (c *Client) SendOORWithPolicyAndKeyDetailsLabel(ctx context.Context, + amountSat int64, recipientPolicyTemplate []byte, + idempotencyKey string, receiveScriptLabel string) (*OORSendResult, + error) { + resp, err := c.SendOOR(ctx, &daemonrpc.SendOORRequest{ Recipients: []*daemonrpc.Output{ { @@ -909,7 +935,8 @@ func (c *Client) SendOORWithPolicyAndKeyDetails(ctx context.Context, recipientPolicyTemplate..., ), }, - AmountSat: amountSat, + AmountSat: amountSat, + ReceiveScriptLabel: receiveScriptLabel, }, }, IdempotencyKey: idempotencyKey, diff --git a/sdk/swaps/client.go b/sdk/swaps/client.go index ffee812bc..7b102742f 100644 --- a/sdk/swaps/client.go +++ b/sdk/swaps/client.go @@ -461,6 +461,12 @@ type DaemonConn interface { SendOORWithPolicyDetails(ctx context.Context, amountSat int64, recipientPolicyTemplate []byte) (*OORSendResult, error) + // SendOORWithPolicyDetailsAndLabel sends an OOR transfer and asks the + // daemon to register the resolved output script with the indexer label. + SendOORWithPolicyDetailsAndLabel(ctx context.Context, amountSat int64, + recipientPolicyTemplate []byte, + receiveScriptLabel string) (*OORSendResult, error) + // SendOORWithCustomInputs sends an OOR with custom inputs into one // standard pubkey-backed Ark receive destination. SendOORWithCustomInputs(ctx context.Context, recipientPubKey []byte, diff --git a/sdk/swaps/in_swap.go b/sdk/swaps/in_swap.go index b400c86cc..818a7421f 100644 --- a/sdk/swaps/in_swap.go +++ b/sdk/swaps/in_swap.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/daemonrpc" "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/vhtlcrecovery" loopfsm "github.com/lightninglabs/loop/fsm" "github.com/lightningnetwork/lnd/lntypes" "google.golang.org/grpc/codes" @@ -821,6 +822,17 @@ func (s *paySession) ensureFundingSubmitted(ctx context.Context, } } + label, err := s.recoveryManifestLabel() + if err != nil { + return fmt.Errorf("build vHTLC recovery manifest: %w", err) + } + + _, err = s.client.daemon.AllocateReceiveScript(ctx, label) + if err != nil { + return fmt.Errorf("register vHTLC recovery manifest: %w", + err) + } + result, err := s.client.daemon.SendOORWithPolicyDetails( ctx, s.cfg.AmountSat, s.vhtlcPolicyTemplate, ) @@ -873,6 +885,56 @@ func (s *paySession) ensureFundingSubmitted(ctx context.Context, return s.markVHTLCFundedFromLocalMetadata(ctx) } +// recoveryManifestLabel returns the indexer label that lets seed restore find +// this funded pay-side vHTLC even when the local swap DB is gone. +func (s *paySession) recoveryManifestLabel() (string, error) { + if s.cfg == nil { + return "", fmt.Errorf("pay swap config is required") + } + + sender, err := pubKeyBytesForRecovery(s.clientPubKey, "sender") + if err != nil { + return "", err + } + receiver, err := pubKeyBytesForRecovery(s.serverPubKey, "receiver") + if err != nil { + return "", err + } + server, err := pubKeyBytesForRecovery(s.operatorPubKey, "server") + if err != nil { + return "", err + } + + return vhtlcrecovery.EncodeManifestLabel( + vhtlcrecovery.RecoveryManifest{ + Role: vhtlcrecovery.ManifestRoleSender, + Direction: vhtlcrecovery.ManifestDirectionPay, + PaymentHash: append( + []byte(nil), s.cfg.PaymentHash[:]..., + ), + SenderPubkey: sender, + ReceiverPubkey: receiver, + ServerPubkey: server, + RefundLocktime: s.cfg.VHTLCConfig. + RefundLocktime, + UnilateralClaimDelay: s.cfg.VHTLCConfig. + UnilateralClaimDelay, + UnilateralRefundDelay: s.cfg.VHTLCConfig. + UnilateralRefundDelay, + UnilateralRefundWithoutReceiverDelay: s.cfg. + VHTLCConfig. + UnilateralRefundWithoutReceiverDelay, + PkScript: append( + []byte(nil), s.vhtlcPkScript..., + ), + AmountSat: s.cfg.AmountSat, + SignerKeyFamily: recoverySignerFamily(), + SignerKeyIndex: recoverySignerKeyIndex, + StatusHint: "unsent_in_swap", + }, + ) +} + // markVHTLCFundedFromLocalMetadata records progress from a locally known // funding outpoint. This lets retries recover even if the OOR metadata was // persisted but the subsequent state transition did not make it to the swap diff --git a/sdk/swaps/in_swap_test.go b/sdk/swaps/in_swap_test.go index 88d1ae76a..e5a03ed4d 100644 --- a/sdk/swaps/in_swap_test.go +++ b/sdk/swaps/in_swap_test.go @@ -18,6 +18,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/daemonrpc" swapsqlc "github.com/lightninglabs/darepo-client/sdk/swaps/sqlc" + "github.com/lightninglabs/darepo-client/vhtlcrecovery" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" @@ -544,6 +545,18 @@ func TestPayViaLightningReturnsClaimPreimage(t *testing.T) { require.Equal(t, "funding-session", result.FundingSessionID) require.EqualValues(t, testInSwapFeeSat, result.FeeSat) require.NotEmpty(t, daemonConn.lastSendPolicy) + require.Empty(t, daemonConn.lastSendLabel) + require.Equal(t, 1, daemonConn.receiveAllocCalls) + manifest, ok, err := vhtlcrecovery.DecodeManifestLabel( + daemonConn.lastReceiveLabel, + ) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, vhtlcrecovery.ManifestRoleSender, manifest.Role) + require.Equal(t, vhtlcrecovery.ManifestDirectionPay, manifest.Direction) + paymentHash := preimage.Hash() + require.Equal(t, paymentHash[:], manifest.PaymentHash) + require.EqualValues(t, testInSwapAmountSat, manifest.AmountSat) } // TestPayViaLightningRequiresClaimPreimage asserts the pay FSM never treats an @@ -1831,7 +1844,7 @@ func TestPaySessionCancelDoesNotPersistFailed(t *testing.T) { require.NoError(t, err) waitCtx, cancel := context.WithTimeout( - t.Context(), 5*time.Millisecond, + t.Context(), 50*time.Millisecond, ) defer cancel() diff --git a/sdk/swaps/out_swap_test.go b/sdk/swaps/out_swap_test.go index a5b0e1de9..9520db56b 100644 --- a/sdk/swaps/out_swap_test.go +++ b/sdk/swaps/out_swap_test.go @@ -1026,6 +1026,8 @@ type testDaemonConn struct { liveLookupCalls int spentLookupCalls int lastSendPolicy []byte + lastSendLabel string + lastReceiveLabel string lastClaimPubKey []byte lastClaimInput []CustomInput lastOORSessionID string @@ -1053,13 +1055,25 @@ func (d *testDaemonConn) BlockHeight(context.Context) (uint32, error) { } // SendOORWithPolicyDetails records the requested output policy template. -func (d *testDaemonConn) SendOORWithPolicyDetails(_ context.Context, _ int64, +func (d *testDaemonConn) SendOORWithPolicyDetails(ctx context.Context, _ int64, recipientPolicyTemplate []byte) (*OORSendResult, error) { + return d.SendOORWithPolicyDetailsAndLabel( + ctx, 0, recipientPolicyTemplate, "", + ) +} + +// SendOORWithPolicyDetailsAndLabel records the requested output policy +// template and indexer registration label. +func (d *testDaemonConn) SendOORWithPolicyDetailsAndLabel( + _ context.Context, _ int64, recipientPolicyTemplate []byte, + receiveScriptLabel string) (*OORSendResult, error) { + d.sendPolicyCalls++ d.lastSendPolicy = append( []byte(nil), recipientPolicyTemplate..., ) + d.lastSendLabel = receiveScriptLabel if d.sendPolicyErr != nil { return nil, d.sendPolicyErr @@ -1438,10 +1452,11 @@ func (d *testDaemonConn) GetIndexedOORSession(context.Context, []byte, string) ( } // AllocateReceiveScript returns the configured receive info. -func (d *testDaemonConn) AllocateReceiveScript(context.Context, string) ( - *ReceiveInfo, error) { +func (d *testDaemonConn) AllocateReceiveScript(_ context.Context, + label string) (*ReceiveInfo, error) { d.receiveAllocCalls++ + d.lastReceiveLabel = label if d.receiveInfo == nil { if d.identityKey == nil { return nil, nil diff --git a/sdk/walletdk/client.go b/sdk/walletdk/client.go index 4c6be1c87..c6fb97bc9 100644 --- a/sdk/walletdk/client.go +++ b/sdk/walletdk/client.go @@ -217,6 +217,9 @@ func (c *Client) CreateWallet(ctx context.Context, req CreateWalletRequest) ( RecoveredOORReceiveScripts: initResp. GetRecoveredOorReceiveScripts(), RecoveredOORRecipientEvents: initResp.GetRecoveredOorEvents(), + RecoveredVHTLCs: initResp.GetRecoveredVhtlcs(), + RecoveredVHTLCRefunds: initResp. + GetRecoveredVhtlcRefunds(), }, nil } diff --git a/sdk/walletdk/types.go b/sdk/walletdk/types.go index 087a22e92..ff3a7dab9 100644 --- a/sdk/walletdk/types.go +++ b/sdk/walletdk/types.go @@ -112,6 +112,8 @@ type CreateWalletResult struct { RecoveredVTXOs uint32 RecoveredOORReceiveScripts uint32 RecoveredOORRecipientEvents uint32 + RecoveredVHTLCs uint32 + RecoveredVHTLCRefunds uint32 } // UnlockWalletRequest unlocks an existing embedded daemon wallet. diff --git a/swapwallet/admin.go b/swapwallet/admin.go index 595e9bb5f..740d10921 100644 --- a/swapwallet/admin.go +++ b/swapwallet/admin.go @@ -83,6 +83,8 @@ func (s *Service) create(ctx context.Context, req *walletdkrpc.CreateRequest) ( RecoveredVtxos: initResp.GetRecoveredVtxos(), RecoveredOorReceiveScripts: initResp.GetRecoveredOorReceiveScripts(), RecoveredOorEvents: initResp.GetRecoveredOorEvents(), + RecoveredVhtlcs: initResp.GetRecoveredVhtlcs(), + RecoveredVhtlcRefunds: initResp.GetRecoveredVhtlcRefunds(), }, nil } diff --git a/vhtlcrecovery/manifest.go b/vhtlcrecovery/manifest.go new file mode 100644 index 000000000..4472ad4be --- /dev/null +++ b/vhtlcrecovery/manifest.go @@ -0,0 +1,138 @@ +package vhtlcrecovery + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +const ( + // ManifestLabelPrefix identifies indexer receive-script labels that + // carry vHTLC recovery metadata. + ManifestLabelPrefix = "vhtlc-recovery-v1:" + + // ManifestRoleSender identifies the local vHTLC sender/refunder key. + ManifestRoleSender = "sender" + + // ManifestRoleReceiver identifies the local vHTLC receiver/claimer key. + ManifestRoleReceiver = "receiver" + + // ManifestDirectionPay is the client-funded Ark-to-Lightning swap path. + ManifestDirectionPay = "pay" +) + +// RecoveryManifest is the compact, indexer-stored metadata needed to rebuild a +// wallet-owned vHTLC recovery candidate after seed restore. +type RecoveryManifest struct { + Role string `json:"r"` + Direction string `json:"d"` + PaymentHash []byte `json:"h"` + SenderPubkey []byte `json:"s"` + ReceiverPubkey []byte `json:"rcv"` + ServerPubkey []byte `json:"op"` + RefundLocktime uint32 `json:"rl"` + UnilateralClaimDelay uint32 `json:"uc"` + UnilateralRefundDelay uint32 `json:"ur"` + UnilateralRefundWithoutReceiverDelay uint32 `json:"urwr"` + PkScript []byte `json:"ps,omitempty"` + AmountSat int64 `json:"amt,omitempty"` + SignerKeyFamily int32 `json:"kf"` + SignerKeyIndex int32 `json:"ki"` + StatusHint string `json:"hint,omitempty"` +} + +// EncodeManifestLabel serializes a recovery manifest into an indexer label. +func EncodeManifestLabel(manifest RecoveryManifest) (string, error) { + if err := manifest.validate(); err != nil { + return "", err + } + + encoded, err := json.Marshal(manifest) + if err != nil { + return "", fmt.Errorf("marshal vhtlc recovery manifest: %w", + err) + } + + return ManifestLabelPrefix + base64.RawURLEncoding.EncodeToString( + encoded, + ), nil +} + +// DecodeManifestLabel parses a recovery manifest label. The boolean return is +// false when the label belongs to another receive-script use. +func DecodeManifestLabel(label string) (RecoveryManifest, bool, error) { + if !strings.HasPrefix(label, ManifestLabelPrefix) { + return RecoveryManifest{}, false, nil + } + + raw, err := base64.RawURLEncoding.DecodeString( + strings.TrimPrefix(label, ManifestLabelPrefix), + ) + if err != nil { + return RecoveryManifest{}, true, fmt.Errorf("decode vhtlc "+ + "recovery manifest: %w", err) + } + + var manifest RecoveryManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return RecoveryManifest{}, true, fmt.Errorf("unmarshal vhtlc "+ + "recovery manifest: %w", err) + } + if err := manifest.validate(); err != nil { + return RecoveryManifest{}, true, err + } + + return manifest, true, nil +} + +// validate rejects labels that do not carry enough data to rebuild a recovery +// policy safely. +func (m RecoveryManifest) validate() error { + switch { + case m.Role == "": + return fmt.Errorf("vhtlc recovery manifest role is required") + + case m.Direction == "": + return fmt.Errorf("vhtlc recovery manifest direction is " + + "required") + + case len(m.PaymentHash) != 32: + return fmt.Errorf("vhtlc recovery manifest payment hash " + + "must be 32 bytes") + + case len(m.SenderPubkey) == 0: + return fmt.Errorf("vhtlc recovery manifest sender pubkey is " + + "required") + + case len(m.ReceiverPubkey) == 0: + return fmt.Errorf("vhtlc recovery manifest receiver " + + "pubkey is required") + + case len(m.ServerPubkey) == 0: + return fmt.Errorf("vhtlc recovery manifest server pubkey is " + + "required") + + case m.RefundLocktime == 0: + return fmt.Errorf("vhtlc recovery manifest refund " + + "locktime is required") + + case m.UnilateralClaimDelay == 0: + return fmt.Errorf("vhtlc recovery manifest claim delay is " + + "required") + + case m.UnilateralRefundDelay == 0: + return fmt.Errorf("vhtlc recovery manifest refund delay is " + + "required") + + case m.UnilateralRefundWithoutReceiverDelay == 0: + return fmt.Errorf("vhtlc recovery manifest refund-without-" + + "receiver delay is required") + + case len(m.PkScript) == 0: + return fmt.Errorf("vhtlc recovery manifest pk script is " + + "required") + } + + return nil +} diff --git a/vhtlcrecovery/manifest_test.go b/vhtlcrecovery/manifest_test.go new file mode 100644 index 000000000..a8f94ddee --- /dev/null +++ b/vhtlcrecovery/manifest_test.go @@ -0,0 +1,64 @@ +package vhtlcrecovery + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRecoveryManifestLabelRoundTrip(t *testing.T) { + t.Parallel() + + manifest := RecoveryManifest{ + Role: ManifestRoleSender, + Direction: ManifestDirectionPay, + PaymentHash: bytesOf(32, 1), + SenderPubkey: bytesOf(33, 2), + ReceiverPubkey: bytesOf(33, 3), + ServerPubkey: bytesOf(33, 4), + RefundLocktime: 144, + UnilateralClaimDelay: 12, + UnilateralRefundDelay: 24, + UnilateralRefundWithoutReceiverDelay: 36, + PkScript: bytesOf(34, 5), + AmountSat: 42_000, + SignerKeyFamily: 6, + SignerKeyIndex: 0, + StatusHint: "unsent_in_swap", + } + + label, err := EncodeManifestLabel(manifest) + require.NoError(t, err) + require.Contains(t, label, ManifestLabelPrefix) + + decoded, ok, err := DecodeManifestLabel(label) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, manifest, decoded) +} + +func TestDecodeManifestLabelIgnoresOtherLabels(t *testing.T) { + t.Parallel() + + _, ok, err := DecodeManifestLabel("oor receive") + require.NoError(t, err) + require.False(t, ok) +} + +func TestDecodeManifestLabelRejectsCorruptPayload(t *testing.T) { + t.Parallel() + + _, ok, err := DecodeManifestLabel(ManifestLabelPrefix + "not-base64!") + require.Error(t, err) + require.True(t, ok) +} + +// bytesOf returns a deterministic byte slice for manifest round-trip tests. +func bytesOf(length int, value byte) []byte { + out := make([]byte, length) + for i := range out { + out[i] = value + } + + return out +}