skills/fulmine-submarine-swap/SKILL.md
# Fulmine Submarine Swap (ARK → Lightning) ## Overview A submarine swap allows Fulmine users to send payments to Lightning Network invoices using their ARK funds. The user locks funds in a VHTLC that Boltz can claim once the Lightning invoice is paid. ## Flow Diagram ``` 1. User has Lightning invoice to pay 2. Fulmine creates submarine swap with Boltz 3. Boltz provides VHTLC parameters and address 4. Fulmine funds VHTLC with ARK VTXOs 5. Boltz detects funding, pays Lightning invoice 6. Boltz
npx skillsauth add arklabshq/arkadian skills/fulmine-submarine-swapInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
A submarine swap allows Fulmine users to send payments to Lightning Network invoices using their ARK funds. The user locks funds in a VHTLC that Boltz can claim once the Lightning invoice is paid.
1. User has Lightning invoice to pay
2. Fulmine creates submarine swap with Boltz
3. Boltz provides VHTLC parameters and address
4. Fulmine funds VHTLC with ARK VTXOs
5. Boltz detects funding, pays Lightning invoice
6. Boltz claims VHTLC with preimage from paid invoice
If payment fails:
7. Boltz co-signs refund (collaborative) OR
8. User waits for timelock and refunds unilaterally
| File | Purpose |
|------|---------|
| pkg/swap/swap.go | Main SwapHandler, submarineSwap() |
| pkg/boltz/boltz.go | Boltz API client |
| pkg/boltz/types.go | API request/response types |
| pkg/boltz/ws.go | Websocket for swap updates |
| pkg/boltz/status.go | Swap status event types |
| internal/core/domain/swap.go | Domain model |
// pkg/swap/swap.go:97-105
func (h *SwapHandler) PayInvoice(
ctx context.Context, invoice string, unilateralRefund func(swap Swap) error,
) (*Swap, error) {
if len(invoice) <= 0 {
return nil, fmt.Errorf("missing invoice")
}
return h.submarineSwap(ctx, invoice, unilateralRefund)
}
// pkg/swap/swap.go:107-139
func (h *SwapHandler) PayOffer(
ctx context.Context, offer string, lightningUrl string, unilateralRefund func(swap Swap) error,
) (*Swap, error) {
// Decode the offer to get the amount
decodedOffer, err := DecodeBolt12Offer(offer)
amountInSats := decodedOffer.AmountInSats
if amountInSats == 0 {
return nil, fmt.Errorf("offer amount must be greater than 0")
}
// Fetch invoice from offer
boltzApi := h.boltzSvc
if lightningUrl != "" {
boltzApi = &boltz.Api{URL: lightningUrl}
}
response, err := boltzApi.FetchBolt12Invoice(boltz.FetchBolt12InvoiceRequest{
Offer: offer,
Amount: amountInSats,
Note: decodedOffer.DescriptionStr,
})
return h.submarineSwap(ctx, response.Invoice, unilateralRefund)
}
// pkg/swap/swap.go:692-718
func (h *SwapHandler) submarineSwap(
ctx context.Context, invoice string, unilateralRefund func(swap Swap) error,
) (*Swap, error) {
var preimageHash []byte
// Handle BOLT12 vs BOLT11
if IsBolt12Invoice(invoice) {
decodedInvoice, err := DecodeBolt12Invoice(invoice)
preimageHash = decodedInvoice.PaymentHash160
} else {
_, hash, err := decodeInvoice(invoice)
preimageHash = hash
}
// pkg/swap/swap.go:720-729
swap, err := h.boltzSvc.CreateSwap(boltz.CreateSwapRequest{
From: boltz.CurrencyArk,
To: boltz.CurrencyBtc,
Invoice: invoice,
RefundPublicKey: hex.EncodeToString(h.publicKey.SerializeCompressed()),
PaymentTimeout: h.timeout,
})
// pkg/boltz/types.go:31-48
type CreateSwapRequest struct {
From Currency `json:"from"` // "ARK"
To Currency `json:"to"` // "BTC"
RefundPublicKey string `json:"refundPublicKey"`
Invoice string `json:"invoice,omitempty"`
PaymentTimeout uint32 `json:"paymentTimeout,omitempty"`
}
type CreateSwapResponse struct {
Id string `json:"id"`
Address string `json:"address"` // VHTLC address
AcceptZeroConf bool `json:"acceptZeroConf"`
ExpectedAmount uint64 `json:"expectedAmount"`
ClaimPublicKey string `json:"claimPublicKey"` // Boltz's key
TimeoutBlockHeights TimeoutBlockHeights `json:"timeoutBlockHeights"`
Error string `json:"error"`
}
type TimeoutBlockHeights struct {
RefundLocktime uint32 `json:"refund"`
UnilateralClaim uint32 `json:"unilateralClaim"`
UnilateralRefund uint32 `json:"unilateralRefund"`
UnilateralRefundWithoutReceiver uint32 `json:"unilateralRefundWithoutReceiver"`
}
// pkg/swap/swap.go:731-751
receiverPubkey, err := parsePubkey(swap.ClaimPublicKey)
// Build VHTLC to verify Boltz isn't cheating
vhtlcAddress, _, vhtlcOpts, err := h.getVHTLC(
ctx,
receiverPubkey, // Boltz as receiver (will claim)
nil, // Fulmine as sender (derived from h.publicKey)
preimageHash,
arklib.AbsoluteLocktime(swap.TimeoutBlockHeights.RefundLocktime),
parseLocktime(swap.TimeoutBlockHeights.UnilateralClaim),
parseLocktime(swap.TimeoutBlockHeights.UnilateralRefund),
parseLocktime(swap.TimeoutBlockHeights.UnilateralRefundWithoutReceiver),
)
// Verify address matches what Boltz provided
if swap.Address != vhtlcAddress {
return nil, fmt.Errorf("boltz is trying to scam us, vHTLCs do not match")
}
// pkg/swap/swap.go:753-757
ws := h.boltzSvc.NewWebsocket()
if err := ws.ConnectAndSubscribe(ctx, []string{swap.Id}, 5*time.Second); err != nil {
return nil, err
}
// pkg/swap/swap.go:758-774
receivers := []types.Receiver{{To: swap.Address, Amount: swap.ExpectedAmount}}
var txid string
for range 3 { // Retry up to 3 times
txid, err = h.arkClient.SendOffChain(ctx, receivers)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "vtxo_already_spent") {
continue // Retry on VTXO conflict
}
return nil, fmt.Errorf("failed to pay to vHTLC address: %v", err)
}
break
}
// pkg/swap/swap.go:793-843
for {
select {
case update, ok := <-ws.Updates:
if !ok {
// Reconnect websocket on disconnect
nextWs := h.boltzSvc.NewWebsocket()
nextWs.ConnectAndSubscribe(ctx, []string{swap.Id}, 5*time.Second)
ws = nextWs
continue
}
switch boltz.ParseEvent(update.Status) {
case boltz.TransactionLockupFailed, boltz.InvoiceFailedToPay:
// Payment failed - refund
swapDetails.Status = SwapFailed
withReceiver := true
txid, err := h.RefundSwap(context.Background(),
SwapTypeSubmarine, swap.Id, withReceiver, *vhtlcOpts)
if err != nil {
// Collaborative refund failed - schedule unilateral
go func() {
if err := unilateralRefund(*swapDetails); err != nil {
log.WithError(err).Errorf(
"failed to refund swap %s unilaterally", swap.Id)
}
}()
}
swapDetails.RedeemTxid = txid
return swapDetails, nil
case boltz.TransactionClaimed, boltz.InvoiceSettled:
// Success! Boltz claimed with preimage
swapDetails.Status = SwapSuccess
return swapDetails, nil
}
case <-ctx.Done():
// Timeout - trigger unilateral refund
swapDetails.Status = SwapFailed
go func() {
if err := unilateralRefund(*swapDetails); err != nil {
log.WithError(err).Errorf("failed to refund swap %s", swap.Id)
}
}()
return swapDetails, nil
}
}
// pkg/boltz/status.go:3-30
type SwapUpdateEvent int
const (
SwapCreated SwapUpdateEvent = iota
SwapExpired
InvoiceSet
InvoicePaid
InvoicePending
InvoiceSettled // Success - invoice paid
InvoiceFailedToPay // Failure - need refund
TransactionFailed
TransactionMempool
TransactionClaimed // Success - Boltz claimed VHTLC
TransactionRefunded
TransactionConfirmed
TransactionLockupFailed // Failure - need refund
TransactionClaimPending
// ...
)
var swapUpdateEventStrings = map[string]SwapUpdateEvent{
"invoice.settled": InvoiceSettled,
"invoice.failedToPay": InvoiceFailedToPay,
"transaction.claimed": TransactionClaimed,
"transaction.lockupFailed": TransactionLockupFailed,
// ...
}
// pkg/swap/swap.go:309-519 (RefundSwap)
func (h *SwapHandler) RefundSwap(
ctx context.Context, swapType, swapId string, withReceiver bool, vhtlcOpts vhtlc.Opts,
) (string, error) {
vhtlcScript, err := vhtlc.NewVHTLCScriptFromOpts(vhtlcOpts)
vtxos, err := h.getVHTLCFunds(ctx, []*vhtlc.VHTLCScript{vhtlcScript})
// If VTXO is recoverable, use batch settlement
if vtxo.IsRecoverable() && vtxo.Amount >= h.config.Dust {
return h.SettleVhtlcWithRefundPath(ctx, vhtlcOpts)
}
// Build refund transaction
refundTapscript, err := vhtlcScript.RefundTapscript(withReceiver)
refundTx, checkpointPtxs, err := offchain.BuildTxs(
[]offchain.VtxoInput{{
RevealedTapscripts: vhtlcScript.GetRevealedTapscripts(),
Outpoint: vtxoOutpoint,
Amount: amount,
Tapscript: refundTapscript,
}},
[]*wire.TxOut{{Value: amount, PkScript: dest}},
checkpointExitScript(h.config),
)
// User signs
signedRefundTx, err := signTransaction(refundTx)
signedCheckpointTx, err := signTransaction(checkpointPtxs[0])
// If withReceiver, get Boltz to co-sign
if withReceiver {
boltzSignedRefundPtx, boltzSignedCheckpointPtx, err := h.collaborativeRefund(
swapType, swapId, unsignedRefundTx, unsignedCheckpointTx)
// Merge Boltz signatures into PSBTs
}
// Submit to ASP
arkTxid, finalRefundTx, serverSignedCheckpoints, err := h.transportClient.SubmitTx(
ctx, signedRefund, []string{unsignedCheckpointTx})
err = h.transportClient.FinalizeTx(ctx, arkTxid, finalCheckpoints)
return arkTxid, nil
}
// pkg/boltz/boltz.go:79-90
func (boltz *Api) RefundSubmarine(swapId string, request RefundSwapRequest) (*RefundSwapResponse, error) {
url := fmt.Sprintf("/swap/submarine/%s/refund/ark", swapId)
resp, err := sendPostRequest[RefundSwapResponse](boltz, url, request)
return resp, nil
}
// pkg/boltz/types.go:94-103
type RefundSwapRequest struct {
Transaction string `json:"transaction"` // Unsigned refund PSBT
Checkpoint string `json:"checkpoint"` // Unsigned checkpoint PSBT
}
type RefundSwapResponse struct {
Transaction string `json:"transaction"` // Boltz-signed refund PSBT
Checkpoint string `json:"checkpoint"` // Boltz-signed checkpoint PSBT
Error string `json:"error"`
}
// internal/core/domain/swap.go:9-36
type SwapStatus int
const (
SwapPending SwapStatus = iota
SwapFailed
SwapSuccess
)
type SwapType int
const (
SwapRegular SwapType = iota // Submarine swap
SwapPayment // Direct payment
)
type Swap struct {
Id string
Amount uint64
Timestamp int64
To boltz.Currency // "BTC" for submarine
From boltz.Currency // "ARK" for submarine
Status SwapStatus
Type SwapType
Invoice string
Vhtlc Vhtlc
FundingTxId string // Txid that funded VHTLC
RedeemTxId string // Txid of claim or refund
}
// pkg/boltz/ws.go:56-70
func (boltz *Api) NewWebsocket() *Websocket {
httpTransport, ok := boltz.Client.Transport.(*http.Transport)
dialer := *websocket.DefaultDialer
if ok {
dialer.Proxy = httpTransport.Proxy
}
return &Websocket{
apiUrl: boltz.WSURL,
subscriptions: make(chan bool),
dialer: &dialer,
Updates: make(chan SwapUpdate),
}
}
// pkg/boltz/ws.go:233-259
func (boltz *Websocket) ConnectAndSubscribe(
ctx context.Context, swapIds []string, retryInterval time.Duration,
) error {
// Connect with retry
err := Retry(ctx, retryInterval, func(ctx context.Context) (bool, error) {
err := boltz.Connect()
return err == nil, nil
})
// Subscribe with retry
err = Retry(ctx, retryInterval, func(ctx context.Context) (bool, error) {
err = boltz.Subscribe(swapIds)
return err == nil, nil
})
return nil
}
// Retry funding if VTXO spent (race condition)
for range 3 {
txid, err = h.arkClient.SendOffChain(ctx, receivers)
if strings.Contains(strings.ToLower(err.Error()), "vtxo_already_spent") {
continue
}
break
}
case update, ok := <-ws.Updates:
if !ok {
// Channel closed - reconnect
oldWs := ws
nextWs := h.boltzSvc.NewWebsocket()
nextWs.ConnectAndSubscribe(ctx, []string{swap.Id}, 5*time.Second)
_ = oldWs.Close()
ws = nextWs
continue
}
// Try collaborative first
txid, err := h.RefundSwap(context.Background(), SwapTypeSubmarine, swap.Id, withReceiver, *vhtlcOpts)
if err != nil {
// Collaborative failed - schedule unilateral (async)
go func() {
if err := unilateralRefund(*swapDetails); err != nil {
log.WithError(err).Errorf("failed to refund swap %s unilaterally", swap.Id)
}
}()
}
| Role | Who | Keys |
|------|-----|------|
| Sender | Fulmine user | h.publicKey |
| Receiver | Boltz | swap.ClaimPublicKey |
| Server | ASP | h.config.SignerPubKey |
SendOffChain()TransactionClaimed or InvoiceSettledInvoiceFailedToPay or TransactionLockupFailedSwapFailed statusfulmine-vhtlc - VHTLC construction for swapsfulmine-reverse-swap - Lightning → ARK swapsark-sdk-payments - SendOffChain() for fundingdocumentation
Update project documentation based on new commits and changes in the repository. Use when: user wants to sync docs after project changes.
testing
Remove a project from the Arkadian documentation registry and delete all associated documentation files. Use when: user wants to deregister a project.
tools
RESTRICTED to ark-project-manager. Generate actionable, dependency-ordered task lists organized by user story.
testing
RESTRICTED to ark-project-manager. Create or update feature specifications from natural language descriptions.