skills/ark-sdk-payments/SKILL.md
Off-chain payment operations with go-sdk - SendOffChain, coin selection, receivers, change handling
npx skillsauth add arklabshq/arkadian ark-sdk-paymentsInstall 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.
Use this skill when:
┌─────────────────────────────────────────────────────────────────┐
│ SENDOFFCHAIN FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Validate receivers (all must be off-chain Ark addresses) │
│ │ │
│ ▼ │
│ 2. Get spendable VTXOs from wallet │
│ │ │
│ ▼ │
│ 3. Coin selection (select enough VTXOs to cover amount + dust) │
│ │ │
│ ▼ │
│ 4. Calculate change (if any) and add change output │
│ │ │
│ ▼ │
│ 5. Build Ark TX + Checkpoint TXs │
│ │ │
│ ▼ │
│ 6. Sign locally (wallet signs forfeit path) │
│ │ │
│ ▼ │
│ 7. Submit to server (SubmitTx) for co-signing │
│ │ │
│ ▼ │
│ 8. Verify server signatures │
│ │ │
│ ▼ │
│ 9. Finalize TX (sign checkpoints, submit FinalizeTx) │
│ │ │
│ ▼ │
│ 10. Update local DB (mark spent, add change VTXO) │
│ │
└─────────────────────────────────────────────────────────────────┘
type Receiver struct {
To string // Ark address (bech32m) or BTC address
Amount uint64 // Amount in satoshis
}
ark1... - instant, free, within Ark networkbc1... - requires CollaborativeExit, costs feesSDK automatically selects VTXOs to cover payment amount:
func sendPayment(client arksdk.ArkClient, toAddr string, amount uint64) (string, error) {
ctx := context.Background()
receivers := []types.Receiver{
{To: toAddr, Amount: amount},
}
// Returns Ark TX ID on success
txid, err := client.SendOffChain(ctx, receivers)
if err != nil {
return "", err
}
return txid, nil
}
// Disable expiry-based sorting (random selection)
txid, err := client.SendOffChain(ctx, receivers,
arksdk.WithoutExpirySorting(),
)
// Select from specific outpoints only
txid, err := client.SendOffChain(ctx, receivers,
arksdk.WithOutpointsFilter([]types.Outpoint{
{Txid: "abc123...", VOut: 0},
{Txid: "def456...", VOut: 1},
}),
)
receivers := []types.Receiver{
{To: "ark1alice...", Amount: 10000},
{To: "ark1bob...", Amount: 20000},
{To: "ark1carol...", Amount: 15000},
}
txid, err := client.SendOffChain(ctx, receivers)
// Single TX pays all receivers
func (a *arkClient) SendOffChain(ctx context.Context, receivers []types.Receiver, opts ...Option) (string, error) {
// All receivers must be off-chain addresses
for _, receiver := range receivers {
if receiver.IsOnchain() {
return "", fmt.Errorf("all receiver addresses must be offchain addresses")
}
// Validate it's a valid Ark address
addr, err := arklib.DecodeAddressV0(receiver.To)
if err != nil {
return "", fmt.Errorf("invalid receiver address: %s", err)
}
// Verify signer matches our server's signer
rcvSignerPubkey := schnorr.SerializePubKey(addr.Signer)
if !bytes.Equal(expectedSignerPubkey, rcvSignerPubkey) {
return "", fmt.Errorf(
"invalid receiver address '%s': expected signer pubkey %x, got %x",
receiver.To, expectedSignerPubkey, rcvSignerPubkey,
)
}
}
// ...
}
Source: go-sdk/client.go:270-289
// Get spendable VTXOs
spendableVtxos, err := a.getVtxos(ctx, &CoinSelectOptions{
WithoutExpirySorting: options.withoutExpirySorting,
})
// Filter VTXOs that belong to our addresses
vtxos := make([]client.TapscriptsVtxo, 0)
for _, offchainAddr := range offchainAddrs {
for _, v := range spendableVtxos {
if v.IsRecoverable() {
continue // Skip swept VTXOs
}
vtxoAddr, _ := v.Address(a.SignerPubKey, a.Network)
if vtxoAddr == offchainAddr.Address {
vtxos = append(vtxos, client.TapscriptsVtxo{
Vtxo: v,
Tapscripts: offchainAddr.Tapscripts,
})
}
}
}
// Select coins for payment
_, selectedCoins, changeAmount, err := utils.CoinSelect(
nil, // No boarding UTXOs for off-chain
vtxos, // Available VTXOs
receivers, // Payment receivers
a.Dust, // Dust threshold
options.withoutExpirySorting, // Sorting preference
nil, // No extra filter
)
Source: go-sdk/client.go:301-335
// If there's change, add a change output back to ourselves
if changeAmount > 0 {
receivers = append(receivers, types.Receiver{
To: offchainAddrs[0].Address, // Our first address
Amount: changeAmount,
})
}
Source: go-sdk/client.go:337-341
// For each selected VTXO, get the forfeit closure
inputs := make([]arkTxInput, 0, len(selectedCoins))
for _, coin := range selectedCoins {
vtxoScript, _ := script.ParseVtxoScript(coin.Tapscripts)
forfeitClosure := vtxoScript.ForfeitClosures()[0]
forfeitScript, _ := forfeitClosure.Script()
forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript)
inputs = append(inputs, arkTxInput{
coin,
forfeitLeaf.TapHash(),
})
}
// Build the off-chain TX
arkTx, checkpointTxs, err := buildOffchainTx(
inputs,
receivers,
a.CheckpointExitPath(),
a.Dust,
)
Source: go-sdk/client.go:343-369
// Sign with wallet (forfeit path signature)
signedArkTx, err := a.wallet.SignTransaction(ctx, a.explorer, arkTx)
// Submit to server for co-signing
arkTxid, signedArkTx, signedCheckpointTxs, err := a.client.SubmitTx(
ctx, signedArkTx, checkpointTxs,
)
// Verify server signatures are valid
if err := verifySignedArk(arkTx, signedArkTx, a.SignerPubKey); err != nil {
return "", err
}
if err := verifySignedCheckpoints(checkpointTxs, signedCheckpointTxs, a.SignerPubKey); err != nil {
return "", err
}
Source: go-sdk/client.go:371-390
// Finalize = sign checkpoints and notify server
txid, err := a.finalizeTx(ctx, client.AcceptedOffchainTx{
Txid: arkTxid,
FinalArkTx: signedArkTx,
SignedCheckpointTxs: signedCheckpointTxs,
})
Source: go-sdk/client.go:392-399
// Mark spent VTXOs
spentVtxos := make([]types.Vtxo, 0, len(selectedCoins))
for i, vtxo := range selectedCoins {
checkpointTx, _ := psbt.NewFromRawBytes(strings.NewReader(signedCheckpointTxs[i]), true)
vtxo.Spent = true
vtxo.ArkTxid = arkTxid
vtxo.SpentBy = checkpointTx.UnsignedTx.TxID()
spentVtxos = append(spentVtxos, vtxo.Vtxo)
}
a.store.VtxoStore().UpdateVtxos(ctx, spentVtxos)
// Add change VTXO if any
if changeAmount > 0 {
a.store.VtxoStore().AddVtxos(ctx, []types.Vtxo{
{
Outpoint: types.Outpoint{Txid: arkTxid, VOut: uint32(len(receivers) - 1)},
Amount: changeAmount,
Script: hex.EncodeToString(changeScript),
// Inherit expiration from inputs
ExpiresAt: smallestExpiration,
},
})
}
// Record transaction
a.store.TransactionStore().AddTransactions(ctx, []types.Transaction{
{
TransactionKey: types.TransactionKey{ArkTxid: arkTxid},
Amount: spentAmount,
Type: types.TxSent,
CreatedAt: time.Now(),
},
})
Source: go-sdk/client.go:405-521
func (a *arkClient) RedeemNotes(ctx context.Context, notes []string, opts ...Option) (string, error) {
// Notes are pre-funded VTXOs that can be redeemed
for _, noteStr := range notes {
v, err := note.NewNoteFromString(noteStr)
if err != nil {
return "", err
}
// Process note...
}
// Similar flow to SendOffChain but using notes as inputs
}
Source: go-sdk/client.go:526-548
type CoinSelectOptions struct {
// Don't sort by expiry (default: sorts oldest first)
WithoutExpirySorting bool
// Only select from these specific outpoints
OutpointsFilter []types.Outpoint
// Include swept but unspent VTXOs
WithRecoverableVtxos bool
// Only VTXOs expiring before this threshold (seconds)
ExpiryThreshold int64
// Recompute expiration from ancestor leaves
RecomputeExpiry bool
}
Source: go-sdk/types.go:134-145
| Purpose | File | Key Functions |
|---------|------|---------------|
| SendOffChain | go-sdk/client.go | SendOffChain, RedeemNotes |
| Coin selection | go-sdk/internal/utils/coinselect.go | CoinSelect |
| Ark TX building | go-sdk/client.go | buildOffchainTx |
| Options | go-sdk/options.go | WithoutExpirySorting, etc. |
| Types | go-sdk/types/types.go | Receiver, Vtxo, Transaction |
txid, err := client.SendOffChain(ctx, []types.Receiver{
{To: recipientArkAddr, Amount: 10000},
})
// Before payment
balance1, _ := client.Balance(ctx)
fmt.Printf("Before: %d sats\n", balance1.OffchainBalance.Total)
// Send payment
txid, _ := client.SendOffChain(ctx, []types.Receiver{
{To: recipientAddr, Amount: 5000},
})
// After payment (change is automatically handled)
balance2, _ := client.Balance(ctx)
fmt.Printf("After: %d sats\n", balance2.OffchainBalance.Total)
balance, _ := client.Balance(ctx)
total := balance.OffchainBalance.Total
// Send everything (no change)
txid, err := client.SendOffChain(ctx, []types.Receiver{
{To: recipientAddr, Amount: total},
})
func isValidArkAddress(addr string, expectedSigner *btcec.PublicKey) bool {
decoded, err := arklib.DecodeAddressV0(addr)
if err != nil {
return false // Invalid format
}
// Check signer matches expected server
return bytes.Equal(
schnorr.SerializePubKey(decoded.Signer),
schnorr.SerializePubKey(expectedSigner),
)
}
Off-chain Only: SendOffChain only supports Ark addresses. Use CollaborativeExit for on-chain withdrawals.
Signer Validation: Receiver addresses must have the same signer as your server. Cross-server payments fail.
Dust Threshold: Amounts below dust threshold are handled specially (sub-dust outputs marked as recoverable).
Change Expiration: Change VTXOs inherit the earliest expiration from input VTXOs. Plan refreshes accordingly.
Server Co-sign: Payment requires server to co-sign. If server is down, payment fails.
DB Mutex: SendOffChain holds a DB lock. Don't call other methods that acquire DB lock during the call.
Transaction Feed: If WithTransactionFeed: true, local state is updated. Otherwise, no local state change.
Recoverable VTXOs: Normal payments skip swept-but-unspent VTXOs. Use WithRecoverableVtxos to include them.
Verify Signatures: SDK verifies server signatures before finalization. Don't skip this verification.
Batch Payments: Multiple receivers in one TX is more efficient than multiple single-receiver TXs.
Skill Owner: ark-developer Repos: go-sdk
documentation
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.