diff --git a/examples/cardano_poc/config.yaml.template b/examples/cardano_poc/config.yaml.template new file mode 100644 index 0000000..266fb74 --- /dev/null +++ b/examples/cardano_poc/config.yaml.template @@ -0,0 +1,22 @@ +# Blockfrost API configuration for the Cardano PoC +blockfrost_project_id: "preprod....." # Replace with your actual preprod project ID +# Network / endpoints +# Supported: preprod (default), preview +network: "preprod" + +# Override Blockfrost base URL if needed +# blockfrost_base_url: "https://cardano-preprod.blockfrost.io/api/v0" + +# TTL in seconds (slot length is 1s on these testnets) +ttl_seconds: 3600 + +# PoC safety threshold for change output (ADA-only). +min_change_lovelace: 1000000 + +# NATS Jetstream configuration +nats: + url: "nats://localhost:4222" + +# Event initiator key algorithm (ed25519 or p256) +event_initiator_algorithm: "ed25519" + diff --git a/examples/cardano_poc/create_wallet.go b/examples/cardano_poc/create_wallet.go new file mode 100644 index 0000000..a227bdc --- /dev/null +++ b/examples/cardano_poc/create_wallet.go @@ -0,0 +1,130 @@ +//go:build create_wallet + +package main + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "time" + + "github.com/fystack/mpcium/pkg/client" + "github.com/fystack/mpcium/pkg/config" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + "github.com/spf13/viper" +) + +// Usage: +// go run -tags=create_wallet ./examples/cardano_poc +// Output: +// Prints wallet_id and deposit address (enterprise) to stdout. +// +// Note: this command does NOT wait for funding. + +func main() { + const environment = "development" + config.InitViperConfig("examples/cardano_poc/config.yaml") + logger.Init(environment, true) + + algorithm := viper.GetString("event_initiator_algorithm") + if algorithm == "" { + algorithm = string(types.EventInitiatorKeyTypeEd25519) + } + if !slices.Contains([]string{string(types.EventInitiatorKeyTypeEd25519), string(types.EventInitiatorKeyTypeP256)}, algorithm) { + logger.Fatal(fmt.Sprintf("invalid algorithm: %s", algorithm), nil) + } + + natsURL := viper.GetString("nats.url") + natsConn, err := nats.Connect(natsURL) + if err != nil { + logger.Fatal("Failed to connect to NATS", err) + } + defer natsConn.Drain() + defer natsConn.Close() + + localSigner, err := client.NewLocalSigner(types.EventInitiatorKeyType(algorithm), client.LocalSignerOptions{KeyPath: "./event_initiator.key"}) + if err != nil { + logger.Fatal("Failed to create local signer", err) + } + + mpcClient := client.NewMPCClient(client.Options{NatsConn: natsConn, Signer: localSigner}) + + walletID := uuid.New().String() + created := make(chan event.KeygenResultEvent, 1) + err = mpcClient.OnWalletCreationResult(func(evt event.KeygenResultEvent) { + if evt.WalletID == walletID { + created <- evt + } + }) + if err != nil { + logger.Fatal("OnWalletCreationResult subscribe failed", err) + } + + if err := mpcClient.CreateWallet(walletID); err != nil { + logger.Fatal("CreateWallet failed", err) + } + logger.Info("CreateWallet sent", "walletID", walletID) + + select { + case keygen := <-created: + if keygen.ResultType == event.ResultTypeError { + logger.Fatal("Keygen failed: "+keygen.ErrorReason, nil) + } + + rawPub, err := normalizeEd25519PubKey(keygen.EDDSAPubKey) + if err != nil { + logger.Fatal("normalize pubkey failed", err) + } + ourAddr, err := deriveEnterpriseAddressPreprod(rawPub) + if err != nil { + logger.Fatal("derive address failed", err) + } + + // Read existing wallets, add the new one, and write back. + const walletFilePath = "examples/cardano_poc/cardano_poc_wallet.json" + type walletRecord struct { + WalletID string `json:"wallet_id"` + EDDSAPubKeyHex string `json:"eddsa_pubkey_hex"` + DepositAddress string `json:"deposit_address"` + } + + wallets := make(map[string]walletRecord) + b, err := os.ReadFile(walletFilePath) + if err == nil { // if file exists and is readable + if jerr := json.Unmarshal(b, &wallets); jerr != nil { + logger.Fatal("failed to unmarshal existing wallet file", jerr) + } + } else if !os.IsNotExist(err) { // if error is something other than "not found" + logger.Fatal("failed to read wallet file", err) + } + + // Add new wallet + wallets[walletID] = walletRecord{ + WalletID: walletID, + EDDSAPubKeyHex: hex.EncodeToString(keygen.EDDSAPubKey), + DepositAddress: ourAddr, + } + + // Write back to file + recBytes, err := json.MarshalIndent(wallets, "", " ") + if err != nil { + logger.Fatal("failed to marshal wallet file json", err) + } + if werr := os.WriteFile(walletFilePath, recBytes, 0o644); werr != nil { + logger.Fatal("failed to write wallet file", werr) + } + + fmt.Println("wallet_id:", walletID) + fmt.Println("eddsa_pubkey_hex:", hex.EncodeToString(keygen.EDDSAPubKey)) + fmt.Println("deposit_address:", ourAddr) + case <-time.After(60 * time.Second): + logger.Fatal("Timeout waiting for wallet creation", errors.New("timeout")) + } +} diff --git a/examples/cardano_poc/lib.go b/examples/cardano_poc/lib.go new file mode 100644 index 0000000..839f590 --- /dev/null +++ b/examples/cardano_poc/lib.go @@ -0,0 +1,625 @@ +package main + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/cosmos/btcutil/bech32" + "github.com/fxamacker/cbor/v2" + "github.com/spf13/viper" + "golang.org/x/crypto/blake2b" +) + +type bfConfig struct { + ProjectID string + BaseURL string // e.g. https://cardano-preprod.blockfrost.io/api/v0 + Network string // preprod|preview + TTLSeconds uint64 + MinChangeLov uint64 +} + +func loadBFConfig() bfConfig { + cfg := bfConfig{} + cfg.ProjectID = viper.GetString("blockfrost_project_id") + cfg.Network = viper.GetString("network") + if cfg.Network == "" { + cfg.Network = "preprod" + } + cfg.BaseURL = viper.GetString("blockfrost_base_url") + if cfg.BaseURL == "" { + switch strings.ToLower(cfg.Network) { + case "preview": + cfg.BaseURL = "https://cardano-preview.blockfrost.io/api/v0" + default: + cfg.BaseURL = "https://cardano-preprod.blockfrost.io/api/v0" + } + } + cfg.TTLSeconds = uint64(viper.GetInt("ttl_seconds")) + if cfg.TTLSeconds == 0 { + cfg.TTLSeconds = 3600 + } + cfg.MinChangeLov = uint64(viper.GetInt("min_change_lovelace")) + if cfg.MinChangeLov == 0 { + cfg.MinChangeLov = 1_000_000 + } + return cfg +} +// --- Shared helpers (used by the Cardano PoC binaries) --- + +type bfUtxo struct { + TxHash string `json:"tx_hash"` + TxIndex int `json:"tx_index"` + Amount []struct { + Unit string `json:"unit"` + Quantity string `json:"quantity"` + } `json:"amount"` +} + +type simpleUtxo struct { + TxHash string + TxIndex uint32 + Lovelace uint64 + LovelaceOnly bool // PoC flag: true if UTxO contains ONLY lovelace + Assets map[string]uint64 // unit => quantity; unit is "lovelace" or policy+asset hex +} + +func normalizeEd25519PubKey(pk []byte) ([]byte, error) { + if len(pk) == 32 { + return pk, nil + } + if len(pk) == 33 && (pk[0] == 0x02 || pk[0] == 0x03) { + return pk[1:], nil + } + prefix := byte(0) + if len(pk) > 0 { + prefix = pk[0] + } + return nil, fmt.Errorf("unsupported eddsa pubkey format: len=%d prefix=0x%02x", len(pk), prefix) +} + +func deriveEnterpriseAddressPreprod(pubKey []byte) (string, error) { + if len(pubKey) != 32 { + return "", fmt.Errorf("expected 32-byte ed25519 pubkey, got %d", len(pubKey)) + } + h, err := blake2b.New(28, nil) + if err != nil { + return "", err + } + _, _ = h.Write(pubKey) + payKeyHash := h.Sum(nil) + + header := byte(0x60) // enterprise, network id 0 (preprod/testnet) + addrBytes := append([]byte{header}, payKeyHash...) + + data5, err := bech32.ConvertBits(addrBytes, 8, 5, true) + if err != nil { + return "", err + } + encoded, err := bech32.Encode("addr_test", data5) + if err != nil { + return "", err + } + // sanity + _, decoded5, derr := bech32.DecodeNoLimit(encoded) + if derr != nil { + return "", fmt.Errorf("bech32 self-check decode failed: %w", derr) + } + decoded8, derr := bech32.ConvertBits(decoded5, 5, 8, false) + if derr != nil { + return "", fmt.Errorf("bech32 self-check convertbits failed: %w", derr) + } + if !bytes.Equal(decoded8, addrBytes) { + return "", fmt.Errorf("bech32 self-check mismatch") + } + return encoded, nil +} + +func decodeCardanoAddressBytes(addrStr string) ([]byte, error) { + _, data5, err := bech32.DecodeNoLimit(addrStr) + if err != nil { + return nil, fmt.Errorf("failed to decode bech32 address '%s': %w", addrStr, err) + } + data8, err := bech32.ConvertBits(data5, 5, 8, false) + if err != nil { + return nil, fmt.Errorf("failed to convert address bits for '%s': %w", addrStr, err) + } + return data8, nil +} + +type txInput struct { + TxHashHex string + TxIndex uint32 +} + +func buildTxBodyCBOR(inputs []txInput, toAddr string, toLovelace uint64, changeAddr string, changeLovelace uint64, feeLovelace uint64, ttlSlot uint64) ([]byte, error) { + toBytes, err := decodeCardanoAddressBytes(toAddr) + if err != nil { + return nil, err + } + chgBytes, err := decodeCardanoAddressBytes(changeAddr) + if err != nil { + return nil, err + } + + cborInputs := make([]any, 0, len(inputs)) + for _, in := range inputs { + txHash, err := hex.DecodeString(in.TxHashHex) + if err != nil { + return nil, err + } + if len(txHash) != 32 { + return nil, fmt.Errorf("tx hash must be 32 bytes, got %d", len(txHash)) + } + cborInputs = append(cborInputs, []any{txHash, in.TxIndex}) + } + + out1 := []any{toBytes, toLovelace} + outs := []any{out1} + if changeLovelace > 0 { + out2 := []any{chgBytes, changeLovelace} + outs = append(outs, out2) + } + body := map[any]any{0: cborInputs, 1: outs, 2: feeLovelace} + if ttlSlot > 0 { + // 3 = ttl/invalid_hereafter + body[uint64(3)] = ttlSlot + } + return cbor.Marshal(body) +} +func blockfrostGETWithRetry(ctx context.Context, projectID, url string) ([]byte, int, error) { + // Simple PoC retry for 429/5xx + var lastErr error + for i := 0; i < 4; i++ { + b, status, err := blockfrostGET(ctx, projectID, url) + if err == nil && status >= 200 && status < 300 { + return b, status, nil + } + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("HTTP %d: %s", status, prettyJSON(b)) + // no retry for 4xx except 429 + if status >= 400 && status < 500 && status != 429 { + return b, status, lastErr + } + } + time.Sleep(time.Duration(250*(i+1)) * time.Millisecond) + } + return nil, 0, lastErr +} + +func blockfrostPOSTCBORWithRetry(ctx context.Context, projectID, url string, cborBody []byte) ([]byte, int, error) { + var lastErr error + for i := 0; i < 3; i++ { + b, status, err := blockfrostPOSTCBOR(ctx, projectID, url, cborBody) + if err == nil && status >= 200 && status < 300 { + return b, status, nil + } + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("HTTP %d: %s", status, prettyJSON(b)) + if status >= 400 && status < 500 && status != 429 { + return b, status, lastErr + } + } + time.Sleep(time.Duration(300*(i+1)) * time.Millisecond) + } + return nil, 0, lastErr +} + +func buildSignedTxCBOR(txBodyCbor []byte, pubKey []byte, sig []byte) ([]byte, error) { + if len(sig) == 0 { + return nil, errors.New("empty signature") + } + witnessSet := map[any]any{0: []any{[]any{pubKey, sig}}} + var body any + if err := cbor.Unmarshal(txBodyCbor, &body); err != nil { + return nil, err + } + tx := []any{body, witnessSet, true, nil} + return cbor.Marshal(tx) +} + +func findLovelace(amts []struct { + Unit string `json:"unit"` + Quantity string `json:"quantity"` +}) (uint64, error) { + for _, a := range amts { + if a.Unit == "lovelace" { + var v uint64 + _, err := fmt.Sscanf(a.Quantity, "%d", &v) + return v, err + } + } + return 0, errors.New("no lovelace in utxo") +} + +func assetUnitFromPolicyAndName(policyHex, nameHex string) (string, error) { + policyHex = strings.TrimPrefix(policyHex, "0x") + nameHex = strings.TrimPrefix(nameHex, "0x") + if len(policyHex) != 56 { + return "", fmt.Errorf("policy_id_hex must be 56 hex chars, got %d", len(policyHex)) + } + if _, err := hex.DecodeString(policyHex); err != nil { + return "", fmt.Errorf("invalid policy_id_hex: %w", err) + } + if nameHex != "" { + if _, err := hex.DecodeString(nameHex); err != nil { + return "", fmt.Errorf("invalid asset_name_hex: %w", err) + } + } + return policyHex + nameHex, nil +} + +func sumAssetsMaps(dst map[string]uint64, src map[string]uint64) { + for unit, q := range src { + dst[unit] += q + } +} + +func subAssetsMaps(dst map[string]uint64, src map[string]uint64) error { + for unit, q := range src { + cur := dst[unit] + if cur < q { + return fmt.Errorf("insufficient asset %s: have %d need %d", unit, cur, q) + } + if cur == q { + delete(dst, unit) + } else { + dst[unit] = cur - q + } + } + return nil +} + +func assetsMapToCardanoAssets(m map[string]uint64) ([]cardanoAsset, error) { + out := make([]cardanoAsset, 0) + for unit, q := range m { + if unit == "lovelace" { + continue + } + if q == 0 { + continue + } + if len(unit) < 56 { + return nil, fmt.Errorf("invalid asset unit (too short): %s", unit) + } + policy := unit[:56] + name := unit[56:] + out = append(out, cardanoAsset{PolicyIDHex: policy, AssetNameHex: name, Quantity: q}) + } + return out, nil +} + +func blockfrostGET(ctx context.Context, projectID, url string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, 0, err + } + req.Header.Set("project_id", projectID) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return b, resp.StatusCode, err +} + +func blockfrostPOSTCBOR(ctx context.Context, projectID, url string, cborBytes []byte) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(cborBytes)) + if err != nil { + return nil, 0, err + } + req.Header.Set("project_id", projectID) + req.Header.Set("Content-Type", "application/cbor") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return b, resp.StatusCode, err +} + +func prettyJSON(b []byte) string { + var v any + if err := json.Unmarshal(b, &v); err != nil { + return string(b) + } + out, _ := json.MarshalIndent(v, "", " ") + return string(out) +} + +func trimJSONQuotes(s string) string { + s = strings.TrimSpace(s) + return strings.Trim(s, "\"\n\r\t ") +} + +type protocolParams struct { + MinFeeA int `json:"min_fee_a"` + MinFeeB int `json:"min_fee_b"` + CoinsPerUTXOWord string `json:"coins_per_utxo_word"` // Blockfrost returns this as a string + Epoch int `json:"epoch"` + Slot int `json:"slot"` +} + +func fetchProtocolParams(ctx context.Context, cfg bfConfig) (*protocolParams, error) { + url := cfg.BaseURL + "/epochs/latest/parameters" + b, status, err := blockfrostGET(ctx, cfg.ProjectID, url) + if err != nil { + return nil, err + } + if status < 200 || status >= 300 { + return nil, fmt.Errorf("protocol params HTTP %d: %s", status, prettyJSON(b)) + } + + var params protocolParams + if err := json.Unmarshal(b, ¶ms); err != nil { + return nil, err + } + return ¶ms, nil +} +// fetchCurrentSlot returns the latest known slot number from Blockfrost. +func fetchCurrentSlot(ctx context.Context, cfg bfConfig) (uint64, error) { + url := cfg.BaseURL + "/blocks/latest" + b, status, err := blockfrostGET(ctx, cfg.ProjectID, url) + if err != nil { + return 0, err + } + if status < 200 || status >= 300 { + return 0, fmt.Errorf("blocks/latest HTTP %d: %s", status, prettyJSON(b)) + } + var resp struct { + Slot uint64 `json:"slot"` + } + if err := json.Unmarshal(b, &resp); err != nil { + return 0, err + } + if resp.Slot == 0 { + return 0, errors.New("blockfrost returned slot=0") + } + return resp.Slot, nil +} +// --- Cardano multi-asset (token) helpers for the PoC --- + +// cardanoAmount represents an amount in a tx output. +// - For ADA-only: uint64 lovelace +// - For multi-asset: [lovelace, {policyIdBytes: {assetNameBytes: quantity}}] +// CBOR map keys must be raw bytes (policy id 28 bytes, asset name 0..32 bytes). +// This is a simplified model sufficient for common native assets. + +type cardanoAsset struct { + PolicyIDHex string // 56 hex chars => 28 bytes + AssetNameHex string // hex-encoded asset name bytes (can be empty) + Quantity uint64 +} + +func buildTxBodyCBORMultiAsset( + inputs []txInput, + toAddr string, + toLovelace uint64, + toAssets []cardanoAsset, + changeAddr string, + changeLovelace uint64, + changeAssets []cardanoAsset, + feeLovelace uint64, + ttlSlot uint64, +) ([]byte, error) { + toBytes, err := decodeCardanoAddressBytes(toAddr) + if err != nil { + return nil, err + } + chgBytes, err := decodeCardanoAddressBytes(changeAddr) + if err != nil { + return nil, err + } + + cborInputs := make([]any, 0, len(inputs)) + for _, in := range inputs { + txHash, err := hex.DecodeString(in.TxHashHex) + if err != nil { + return nil, err + } + if len(txHash) != 32 { + return nil, fmt.Errorf("tx hash must be 32 bytes, got %d", len(txHash)) + } + cborInputs = append(cborInputs, []any{txHash, in.TxIndex}) + } + + mkAmount := func(lovelace uint64, assets []cardanoAsset) (any, error) { + if len(assets) == 0 { + return lovelace, nil + } + + // IMPORTANT: + // - Go map keys cannot be slices ([]byte) + // - Cardano multi-asset CBOR requires keys to be byte strings (policy_id bytes, asset_name bytes) + // We use hex strings as intermediate keys, then build a CBOR-ready structure + // using cbor.ByteString as map keys (hashable), avoiding "unhashable []uint8" panics. + policies := make(map[string]map[string]uint64) + + for _, a := range assets { + if a.Quantity == 0 { + continue + } + policyHex := strings.TrimPrefix(a.PolicyIDHex, "0x") + assetHex := strings.TrimPrefix(a.AssetNameHex, "0x") + + if len(policyHex) != 56 { + return nil, fmt.Errorf("policy_id_hex must be 56 hex chars (28 bytes), got %d", len(policyHex)) + } + if _, err := hex.DecodeString(policyHex); err != nil { + return nil, fmt.Errorf("invalid policy_id_hex: %w", err) + } + if assetHex != "" { + if _, err := hex.DecodeString(assetHex); err != nil { + return nil, fmt.Errorf("invalid asset_name_hex: %w", err) + } + } + + inner, ok := policies[policyHex] + if !ok { + inner = make(map[string]uint64) + policies[policyHex] = inner + } + inner[assetHex] += a.Quantity + } + + cborPolicies := make(map[any]any) + for policyHex, assetsByName := range policies { + pidBytes, _ := hex.DecodeString(policyHex) + cborInner := make(map[any]any) + for assetHex, qty := range assetsByName { + nameBytes := []byte{} + if assetHex != "" { + nameBytes, _ = hex.DecodeString(assetHex) + } + cborInner[cbor.ByteString(nameBytes)] = qty + } + cborPolicies[cbor.ByteString(pidBytes)] = cborInner + } + + return []any{lovelace, cborPolicies}, nil + } + + toAmt, err := mkAmount(toLovelace, toAssets) + if err != nil { + return nil, err + } + out1 := []any{toBytes, toAmt} + outs := []any{out1} + if changeLovelace > 0 || len(changeAssets) > 0 { + chgAmt, err := mkAmount(changeLovelace, changeAssets) + if err != nil { + return nil, err + } + out2 := []any{chgBytes, chgAmt} + outs = append(outs, out2) + } + + body := map[any]any{0: cborInputs, 1: outs, 2: feeLovelace} + if ttlSlot > 0 { + body[uint64(3)] = ttlSlot + } + return cbor.Marshal(body) +} + + + +func estimateMinAdaForOutput(params *protocolParams, addrBech32 string, lovelace uint64, assets []cardanoAsset) (uint64, error) { + const ( + fallback uint64 = 2_000_000 + minAdaWithToken uint64 = 1_200_000 // Minimum 1.2 ADA is a safe bet for outputs with native tokens. + ) + + if len(assets) > 0 { + // If the output includes any native tokens, enforce the minimum ADA requirement for such outputs. + return minAdaWithToken, nil + } + + // The rest of this function is for ADA-only outputs, which have a lower min-ADA requirement. + coinsPerUTXOWord := 0 + if params != nil { + coinsPerUTXOWord, _ = strconv.Atoi(strings.TrimSpace(params.CoinsPerUTXOWord)) + } + if coinsPerUTXOWord == 0 { + return fallback, nil + } + + addrBytes, err := decodeCardanoAddressBytes(addrBech32) + if err != nil { + return fallback, nil + } + + output := []any{addrBytes, lovelace} + ser, err := cbor.Marshal(output) + if err != nil { + return fallback, nil + } + + minAda := (160 + uint64(len(ser))) * uint64(coinsPerUTXOWord) / 8 + if minAda == 0 { + return fallback, nil + } + return minAda, nil +} + +func parseCardanoAssetArg(s string) (cardanoAsset, error) { + var out cardanoAsset + s = strings.TrimSpace(s) + if s == "" { + return out, errors.New("empty asset spec") + } + + parts := strings.Split(s, ":") + if len(parts) != 2 { + return out, fmt.Errorf("invalid asset spec (missing ':'): %s", s) + } + + idPart := strings.TrimSpace(parts[0]) + qtyPart := strings.TrimSpace(parts[1]) + + var qtyF float64 + if _, err := fmt.Sscanf(qtyPart, "%f", &qtyF); err != nil { + return out, fmt.Errorf("invalid quantity: %w", err) + } + qty := uint64(qtyF * 1_000_000) + + policy := idPart + var assetName string + + if strings.Contains(idPart, ".") { + sp := strings.SplitN(idPart, ".", 2) + policy = sp[0] + assetName = sp[1] + + // Convert ASCII asset names to hex (Blockfrost uses hex-encoded asset names). + if assetName != "" && !isHex(assetName) { + assetName = hex.EncodeToString([]byte(assetName)) + } + } + + out.PolicyIDHex = policy + out.AssetNameHex = assetName + out.Quantity = qty + + return out, nil +} + +// isHex reports whether s contains only hexadecimal characters. +func isHex(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F')) { + return false + } + } + return len(s) > 0 +} + +func parseAmountToAssetsMap(amount []struct { + Unit string `json:"unit"` + Quantity string `json:"quantity"` +}) (map[string]uint64, error) { + assets := make(map[string]uint64) + for _, a := range amount { + qty, err := strconv.ParseUint(a.Quantity, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid quantity for asset %s: %v", a.Unit, err) + } + assets[a.Unit] = qty + } + return assets, nil +} \ No newline at end of file diff --git a/examples/cardano_poc/sign_tx.go b/examples/cardano_poc/sign_tx.go new file mode 100644 index 0000000..237f2d6 --- /dev/null +++ b/examples/cardano_poc/sign_tx.go @@ -0,0 +1,508 @@ +//go:build sign_tx + +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + "time" + + "golang.org/x/crypto/blake2b" + + "github.com/fystack/mpcium/pkg/client" + "github.com/fystack/mpcium/pkg/config" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + "github.com/spf13/viper" +) + +// Usage: +// go run -tags=sign_tx ./examples/cardano_poc --wallet-id --to --amount-ada +// +// Notes: +// - This binary does NOT wait for UTxO confirmation. Fund the deposit address first. +// - It reads pubkey/address from examples/cardano_poc/cardano_poc_wallet.json. +// - For sending native tokens, use sign_tx_token. + +type walletRecord struct { + WalletID string `json:"wallet_id"` + EDDSAPubKeyHex string `json:"eddsa_pubkey_hex"` + DepositAddress string `json:"deposit_address"` +} + + + +func main() { + logger.Init("development", true) + config.InitViperConfig("examples/cardano_poc/config.yaml") + + bfCfg := loadBFConfig() + if bfCfg.ProjectID == "" { + logger.Fatal("blockfrost_project_id is not set in examples/cardano_poc/config.yaml", nil) + } + + params, err := fetchProtocolParams(context.Background(), bfCfg) + if err != nil { + logger.Fatal("failed to fetch protocol params", err) + } + logger.Info("Fetched protocol params", "min_fee_a", params.MinFeeA, "min_fee_b", params.MinFeeB, "network", bfCfg.Network, "base_url", bfCfg.BaseURL) + + walletID, toAddr, amountAda := parseArgsOrFatal() + wf, rawPub, ourAddr := loadWalletOrFatal(walletID) + logger.Info("Using wallet", "wallet_id", wf.WalletID, "address", ourAddr) + + minChangeLovelace := bfCfg.MinChangeLov + sendLovelace := uint64(amountAda * 1_000_000) + // Fail fast: ADA-only outputs must be >= min-UTxO. We use min_change_lovelace as PoC guard. + if sendLovelace < minChangeLovelace { + logger.Fatal(fmt.Sprintf("amount too small: send_lovelace=%d < min_change_lovelace=%d (increase --amount-ada)", sendLovelace, minChangeLovelace), nil) + } + + algorithm := viper.GetString("event_initiator_algorithm") + if algorithm == "" { + algorithm = string(types.EventInitiatorKeyTypeEd25519) + } + + natsURL := viper.GetString("nats.url") + natsConn, err := nats.Connect(natsURL) + if err != nil { + logger.Fatal("Failed to connect to NATS", err) + } + defer natsConn.Drain() + defer natsConn.Close() + + localSigner, err := client.NewLocalSigner(types.EventInitiatorKeyType(algorithm), client.LocalSignerOptions{KeyPath: "./event_initiator.key"}) + if err != nil { + logger.Fatal("Failed to create local signer", err) + } + mpcClient := client.NewMPCClient(client.Options{NatsConn: natsConn, Signer: localSigner}) + + signResultCh := make(chan event.SigningResultEvent, 1) + if err := mpcClient.OnSignResult(func(evt event.SigningResultEvent) { + if evt.WalletID == walletID { + signResultCh <- evt + } + }); err != nil { + logger.Fatal("OnSignResult subscribe failed", err) + } + + submitURL := bfCfg.BaseURL + "/tx/submit" + maxAttempts := 2 + for attempt := 1; attempt <= maxAttempts; attempt++ { + submittedHash, retryableErr, err := buildSignSubmitOnce(context.Background(), bfCfg, params, mpcClient, signResultCh, submitURL, walletID, rawPub, ourAddr, toAddr, sendLovelace, minChangeLovelace) + if err != nil && strings.Contains(err.Error(), "no ADA-only UTxO") { + // Fallback: the wallet may only have multi-asset UTxOs. + // Build a multi-asset tx body that sends only ADA to the recipient, + // while returning all non-ADA assets back to our change output. + submittedHash, retryableErr, err = buildSignSubmitMultiAssetAdaFallback(context.Background(), bfCfg, params, mpcClient, signResultCh, submitURL, walletID, rawPub, ourAddr, toAddr, sendLovelace, minChangeLovelace) + } + if err == nil { + fmt.Println(submittedHash) + fmt.Println("explorer:", "https://preprod.cardanoscan.io/transaction/"+submittedHash) + return + } + logger.Info("Submit attempt failed", "attempt", attempt, "retryable", retryableErr, "error", err.Error()) + if !retryableErr || attempt == maxAttempts { + logger.Fatal("submit failed", err) + } + logger.Info("Rebuilding tx after failure", "next_attempt", attempt+1) + } +} + +func parseArgsOrFatal() (walletID string, toAddr string, amountAda float64) { + amountAda = 1.0 + for i := 1; i < len(os.Args); i++ { + switch os.Args[i] { + case "--wallet-id": + i++ + walletID = os.Args[i] + case "--to": + i++ + toAddr = os.Args[i] + case "--amount-ada": + i++ + _, _ = fmt.Sscanf(os.Args[i], "%f", &amountAda) + } + } + if walletID == "" || toAddr == "" { + logger.Fatal(`Invalid arguments. Usage: + --wallet-id Wallet ID from cardano_poc_wallet.json + --to Recipient address + --amount-ada ADA amount to send`, nil) + } + return +} + +func loadWalletOrFatal(walletID string) (walletRecord, []byte, string) { + const walletFilePath = "examples/cardano_poc/cardano_poc_wallet.json" + b, err := os.ReadFile(walletFilePath) + if err != nil { + logger.Fatal("failed to read wallet file", err) + } + wallets := make(map[string]walletRecord) + if err := json.Unmarshal(b, &wallets); err != nil { + logger.Fatal("failed to unmarshal wallets file", err) + } + wf, ok := wallets[walletID] + if !ok { + logger.Fatal(fmt.Sprintf("wallet with ID %s not found in %s", walletID, walletFilePath), nil) + } + pubKeyBytes, err := hex.DecodeString(wf.EDDSAPubKeyHex) + if err != nil { + logger.Fatal("invalid pubkey hex", err) + } + rawPub, err := normalizeEd25519PubKey(pubKeyBytes) + if err != nil { + logger.Fatal("normalize pubkey failed", err) + } + return wf, rawPub, wf.DepositAddress +} + +func buildSignSubmitOnce( + ctx context.Context, + bfCfg bfConfig, + params *protocolParams, + mpcClient client.MPCClient, + signResultCh <-chan event.SigningResultEvent, + submitURL string, + walletID string, + rawPub []byte, + ourAddr string, + toAddr string, + sendLovelace uint64, + minChangeLovelace uint64, +) (submittedHash string, retryable bool, err error) { + // 1) Fetch UTxOs + utxos, err := fetchAllUtxosOnce(ctx, bfCfg, ourAddr) + if err != nil { + return "", true, err + } + if len(utxos) == 0 { + return "", false, errors.New("no UTxO at address") + } + // For ADA-only send, we must NOT spend UTxOs that also carry tokens. + // If we do, we'd need to reproduce those tokens in outputs; otherwise ValueNotConservedUTxO. + filtered := make([]simpleUtxo, 0, len(utxos)) + for _, u := range utxos { + if u.LovelaceOnly { + filtered = append(filtered, u) + } + } + if len(filtered) == 0 { + return "", false, errors.New("no ADA-only UTxO at address") + } + sort.Slice(filtered, func(i, j int) bool { return filtered[i].Lovelace > filtered[j].Lovelace }) + + // 2) TTL + currentSlot, err := fetchCurrentSlot(ctx, bfCfg) + if err != nil { + return "", true, err + } + ttlSlot := currentSlot + bfCfg.TTLSeconds + logger.Info("Using TTL", "current_slot", currentSlot, "ttl_slot", ttlSlot) + + // 3) Coin selection (simple) + const feeUpperBound uint64 = 600_000 + target := sendLovelace + feeUpperBound + minChangeLovelace + var total uint64 + inputs := make([]txInput, 0, len(filtered)) + for _, u := range filtered { + total += u.Lovelace + inputs = append(inputs, txInput{TxHashHex: u.TxHash, TxIndex: u.TxIndex}) + if total >= target { + break + } + } + logger.Info("Selected UTxOs", "selected", len(inputs), "total_lovelace", total, "target_lovelace", target) + if total < sendLovelace+minChangeLovelace { + return "", false, errors.New("not enough funds") + } + + // 4) Fee converge with dummy witness + dummySig := make([]byte, 64) + var feeLovelace uint64 + var change uint64 + var txBodyCbor []byte + for iter := 0; iter < 3; iter++ { + if total <= sendLovelace+feeLovelace { + return "", false, errors.New("not enough funds after fee") + } + change = total - sendLovelace - feeLovelace + if change > 0 && change < minChangeLovelace { + // omit change output by folding into fee + feeLovelace += change + change = 0 + } + body, err := buildTxBodyCBOR(inputs, toAddr, sendLovelace, ourAddr, change, feeLovelace, ttlSlot) + if err != nil { + return "", false, err + } + dummySigned, err := buildSignedTxCBOR(body, rawPub, dummySig) + if err != nil { + return "", false, err + } + size := len(dummySigned) + newFee := uint64(params.MinFeeA*size + params.MinFeeB) + logger.Info("Calculated fee", "iter", iter, "fee_lovelace", newFee, "signed_tx_size_bytes", size) + if newFee == feeLovelace { + txBodyCbor = body + break + } + feeLovelace = newFee + txBodyCbor = body + } + if txBodyCbor == nil { + return "", false, errors.New("failed to build tx body") + } + + // 5) Tx hash + h := blake2b.Sum256(txBodyCbor) + txHash := h[:] + logger.Info("Prepared tx hash", "tx_hash_hex", hex.EncodeToString(txHash)) + + // 6) Sign + txID := uuid.New().String() + if err := mpcClient.SignTransaction(&types.SignTxMessage{ + KeyType: types.KeyTypeEd25519, + WalletID: walletID, + NetworkInternalCode: "cardano-testnet", + TxID: txID, + Tx: txHash, + }); err != nil { + return "", true, err + } + logger.Info("SignTransaction sent", "txID", txID) + + var res event.SigningResultEvent + select { + case res = <-signResultCh: + if res.ResultType == event.ResultTypeError { + return "", false, errors.New("signing failed: "+res.ErrorReason) + } + case <-time.After(60 * time.Second): + return "", true, errors.New("timeout waiting for signing result") + } + + // 7) Submit + signedTxCbor, err := buildSignedTxCBOR(txBodyCbor, rawPub, res.Signature) + if err != nil { + return "", false, err + } + respBody, status, err := blockfrostPOSTCBORWithRetry(ctx, bfCfg.ProjectID, submitURL, signedTxCbor) + if err != nil { + return "", true, err + } + if status < 200 || status >= 300 { + msg := prettyJSON(respBody) + retryable = strings.Contains(msg, "BadInputsUTxO") || strings.Contains(msg, "InvalidWitnessesUTXOW") || strings.Contains(msg, "ValueNotConservedUTxO") || strings.Contains(msg, "OutsideValidityIntervalUTxO") + return "", retryable, fmt.Errorf("submit HTTP %d: %s", status, msg) + } + _ = json.Unmarshal(respBody, &submittedHash) + if submittedHash == "" { + submittedHash = strings.TrimSpace(string(respBody)) + } + return submittedHash, false, nil +} +// buildSignSubmitMultiAssetAdaFallback builds an ADA-send tx but allows selecting multi-asset UTxOs. +// Any non-lovelace assets present in the selected inputs are returned back to ourAddr as change, +// so ValueNotConservedUTxO is satisfied. +func buildSignSubmitMultiAssetAdaFallback( + ctx context.Context, + bfCfg bfConfig, + params *protocolParams, + mpcClient client.MPCClient, + signResultCh <-chan event.SigningResultEvent, + submitURL string, + walletID string, + rawPub []byte, + ourAddr string, + toAddr string, + sendLovelace uint64, + minChangeLovelace uint64, +) (submittedHash string, retryable bool, err error) { + utxos, err := fetchAllUtxosOnce(ctx, bfCfg, ourAddr) + if err != nil { + return "", true, err + } + if len(utxos) == 0 { + return "", false, errors.New("no UTxO at address") + } + + currentSlot, err := fetchCurrentSlot(ctx, bfCfg) + if err != nil { + return "", true, err + } + ttlSlot := currentSlot + bfCfg.TTLSeconds + logger.Info("Using TTL", "current_slot", currentSlot, "ttl_slot", ttlSlot) + + // coin selection over total lovelace (can include token-carrying utxos) + const feeUpperBound uint64 = 900_000 + target := sendLovelace + feeUpperBound + minChangeLovelace + sort.Slice(utxos, func(i, j int) bool { return utxos[i].Lovelace > utxos[j].Lovelace }) + + inputs := make([]txInput, 0) + totalInputAssets := make(map[string]uint64) + for _, u := range utxos { + inputs = append(inputs, txInput{TxHashHex: u.TxHash, TxIndex: u.TxIndex}) + sumAssetsMaps(totalInputAssets, u.Assets) + if totalInputAssets["lovelace"] >= target { + break + } + } + logger.Info("Selected UTxOs (fallback)", "selected", len(inputs), "total_lovelace", totalInputAssets["lovelace"], "target_lovelace", target) + if totalInputAssets["lovelace"] < sendLovelace+minChangeLovelace { + return "", false, errors.New("not enough funds") + } + + // outputs: receiver gets only lovelace; all other assets go back to change + outputAssets := map[string]uint64{"lovelace": sendLovelace} + + dummySig := make([]byte, 64) + var feeLovelace uint64 + var txBodyCbor []byte + + for iter := 0; iter < 3; iter++ { + changeAssets := make(map[string]uint64) + sumAssetsMaps(changeAssets, totalInputAssets) + + if err := subAssetsMaps(changeAssets, outputAssets); err != nil { + return "", false, fmt.Errorf("value conservation error (outputs): %w", err) + } + if changeAssets["lovelace"] < feeLovelace { + return "", false, errors.New("not enough lovelace for fee") + } + changeAssets["lovelace"] -= feeLovelace + + // dust change handling + if changeAssets["lovelace"] > 0 && changeAssets["lovelace"] < minChangeLovelace { + feeLovelace += changeAssets["lovelace"] + changeAssets["lovelace"] = 0 + } + if changeAssets["lovelace"] == 0 { + delete(changeAssets, "lovelace") + } + + changeAssetsSlice, err := assetsMapToCardanoAssets(changeAssets) + if err != nil { + return "", false, fmt.Errorf("failed to convert change map to slice: %w", err) + } + + body, err := buildTxBodyCBORMultiAsset( + inputs, + toAddr, sendLovelace, nil, + ourAddr, changeAssets["lovelace"], changeAssetsSlice, + feeLovelace, + ttlSlot, + ) + if err != nil { + return "", false, err + } + dummySigned, err := buildSignedTxCBOR(body, rawPub, dummySig) + if err != nil { + return "", false, err + } + size := len(dummySigned) + newFee := uint64(params.MinFeeA*size + params.MinFeeB) + logger.Info("Calculated fee", "iter", iter, "fee_lovelace", newFee, "signed_tx_size_bytes", size) + if newFee == feeLovelace { + txBodyCbor = body + break + } + feeLovelace = newFee + txBodyCbor = body + } + if txBodyCbor == nil { + return "", false, errors.New("failed to build tx body") + } + + h := blake2b.Sum256(txBodyCbor) + txHash := h[:] + logger.Info("Prepared tx hash", "tx_hash_hex", hex.EncodeToString(txHash)) + + txID := uuid.New().String() + if err := mpcClient.SignTransaction(&types.SignTxMessage{ + KeyType: types.KeyTypeEd25519, + WalletID: walletID, + NetworkInternalCode: "cardano-testnet", + TxID: txID, + Tx: txHash, + }); err != nil { + return "", true, err + } + logger.Info("SignTransaction sent", "txID", txID) + + var res event.SigningResultEvent + select { + case res = <-signResultCh: + if res.ResultType == event.ResultTypeError { + return "", false, errors.New("signing failed: " + res.ErrorReason) + } + case <-time.After(60 * time.Second): + return "", true, errors.New("timeout waiting for signing result") + } + + signedTxCbor, err := buildSignedTxCBOR(txBodyCbor, rawPub, res.Signature) + if err != nil { + return "", false, err + } + respBody, status, err := blockfrostPOSTCBORWithRetry(ctx, bfCfg.ProjectID, submitURL, signedTxCbor) + if err != nil { + return "", true, err + } + if status < 200 || status >= 300 { + msg := prettyJSON(respBody) + retryable = strings.Contains(msg, "BadInputsUTxO") || strings.Contains(msg, "InvalidWitnessesUTXOW") || strings.Contains(msg, "ValueNotConservedUTxO") || strings.Contains(msg, "OutsideValidityIntervalUTxO") + return "", retryable, fmt.Errorf("submit HTTP %d: %s", status, msg) + } + _ = json.Unmarshal(respBody, &submittedHash) + if submittedHash == "" { + submittedHash = strings.TrimSpace(string(respBody)) + } + return submittedHash, false, nil +} + + +func fetchAllUtxosOnce(ctx context.Context, cfg bfConfig, addr string) ([]simpleUtxo, error) { + url := cfg.BaseURL + "/addresses/" + addr + "/utxos" + b, status, err := blockfrostGETWithRetry(ctx, cfg.ProjectID, url) + if err != nil { + return nil, err + } + if status == 404 { + return nil, errors.New("address not found on-chain yet (404) - fund it first") + } + if status < 200 || status >= 300 { + return nil, fmt.Errorf("utxo HTTP %d: %s", status, prettyJSON(b)) + } + var raw []bfUtxo + if err := json.Unmarshal(b, &raw); err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, errors.New("no UTxO at address - fund it first") + } + + out := make([]simpleUtxo, 0, len(raw)) + for _, u := range raw { + lovelace, err := findLovelace(u.Amount) + if err != nil { + return nil, err + } + assets, err := parseAmountToAssetsMap(u.Amount) + if err != nil { + return nil, err + } + lovelaceOnly := len(u.Amount) == 1 + out = append(out, simpleUtxo{TxHash: u.TxHash, TxIndex: uint32(u.TxIndex), Lovelace: lovelace, LovelaceOnly: lovelaceOnly, Assets: assets}) + } + return out, nil +} \ No newline at end of file diff --git a/examples/cardano_poc/sign_tx_token.go b/examples/cardano_poc/sign_tx_token.go new file mode 100644 index 0000000..dd14c12 --- /dev/null +++ b/examples/cardano_poc/sign_tx_token.go @@ -0,0 +1,433 @@ +//go:build sign_tx_token + +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + "time" + + "golang.org/x/crypto/blake2b" + + "github.com/fystack/mpcium/pkg/client" + "github.com/fystack/mpcium/pkg/config" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + "github.com/spf13/viper" +) + +// Usage: +// go run -tags=sign_tx_token ./examples/cardano_poc --wallet-id --to --token .: + +type tokenArgs struct { + WalletID string + ToAddr string + Ada float64 + Asset cardanoAsset +} + +type walletRecord struct { + WalletID string `json:"wallet_id"` + EDDSAPubKeyHex string `json:"eddsa_pubkey_hex"` + DepositAddress string `json:"deposit_address"` +} + +func loadWalletOrFatal(walletID string) (walletRecord, []byte, string) { + const walletFilePath = "examples/cardano_poc/cardano_poc_wallet.json" + b, err := os.ReadFile(walletFilePath) + if err != nil { + logger.Fatal("failed to read wallet file", err) + } + wallets := make(map[string]walletRecord) + if err := json.Unmarshal(b, &wallets); err != nil { + logger.Fatal("failed to unmarshal wallets file", err) + } + wf, ok := wallets[walletID] + if !ok { + logger.Fatal(fmt.Sprintf("wallet with ID %s not found in %s", walletID, walletFilePath), nil) + } + pubKeyBytes, err := hex.DecodeString(wf.EDDSAPubKeyHex) + if err != nil { + logger.Fatal("invalid pubkey hex", err) + } + rawPub, err := normalizeEd25519PubKey(pubKeyBytes) + if err != nil { + logger.Fatal("normalize pubkey failed", err) + } + return wf, rawPub, wf.DepositAddress +} + +func fetchAllUtxosOnce(ctx context.Context, cfg bfConfig, addr string) ([]simpleUtxo, error) { + url := cfg.BaseURL + "/addresses/" + addr + "/utxos" + b, status, err := blockfrostGETWithRetry(ctx, cfg.ProjectID, url) + if err != nil { + return nil, err + } + if status == 404 { + return nil, errors.New("address not found on-chain yet (404) - fund it first") + } + if status < 200 || status >= 300 { + return nil, fmt.Errorf("utxo HTTP %d: %s", status, prettyJSON(b)) + } + var raw []bfUtxo + if err := json.Unmarshal(b, &raw); err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, errors.New("no UTxO at address - fund it first") + } + + out := make([]simpleUtxo, 0, len(raw)) + for _, u := range raw { + lovelace, err := findLovelace(u.Amount) + if err != nil { + return nil, err + } + assets, err := parseAmountToAssetsMap(u.Amount) + if err != nil { + return nil, err + } + lovelaceOnly := len(u.Amount) == 1 + out = append(out, simpleUtxo{TxHash: u.TxHash, TxIndex: uint32(u.TxIndex), Lovelace: lovelace, LovelaceOnly: lovelaceOnly, Assets: assets}) + } + return out, nil +} + + +func main() { + logger.Init("development", true) + config.InitViperConfig("examples/cardano_poc/config.yaml") + + bfCfg := loadBFConfig() + if bfCfg.ProjectID == "" { + logger.Fatal("blockfrost_project_id is not set in examples/cardano_poc/config.yaml", nil) + } + + params, err := fetchProtocolParams(context.Background(), bfCfg) + if err != nil { + logger.Fatal("failed to fetch protocol params", err) + } + logger.Info("Fetched protocol params", "min_fee_a", params.MinFeeA, "min_fee_b", params.MinFeeB, "network", bfCfg.Network, "base_url", bfCfg.BaseURL) + + args := parseTokenArgsOrFatal() + _, rawPub, ourAddr := loadWalletOrFatal(args.WalletID) + logger.Info("Using wallet", "wallet_id", args.WalletID, "address", ourAddr) + + minChangeLovelace := bfCfg.MinChangeLov + + // sendAdaLov starts from user input (could be 0) and may be auto-bumped later. + sendAdaLov := uint64(args.Ada * 1_000_000) + + algorithm := viper.GetString("event_initiator_algorithm") + if algorithm == "" { + algorithm = string(types.EventInitiatorKeyTypeEd25519) + } + + natsURL := viper.GetString("nats.url") + natsConn, err := nats.Connect(natsURL) + if err != nil { + logger.Fatal("Failed to connect to NATS", err) + } + defer natsConn.Drain() + defer natsConn.Close() + + localSigner, err := client.NewLocalSigner(types.EventInitiatorKeyType(algorithm), client.LocalSignerOptions{KeyPath: "./event_initiator.key"}) + if err != nil { + logger.Fatal("Failed to create local signer", err) + } + mpcClient := client.NewMPCClient(client.Options{NatsConn: natsConn, Signer: localSigner}) + + signResultCh := make(chan event.SigningResultEvent, 1) + if err := mpcClient.OnSignResult(func(evt event.SigningResultEvent) { + if evt.WalletID == args.WalletID { + signResultCh <- evt + } + }); err != nil { + logger.Fatal("OnSignResult subscribe failed", err) + } + + submitURL := bfCfg.BaseURL + "/tx/submit" + maxAttempts := 2 + for attempt := 1; attempt <= maxAttempts; attempt++ { + submittedHash, retryableErr, err := buildSignSubmitTokenOnce( + context.Background(), bfCfg, params, + mpcClient, signResultCh, + submitURL, + args.WalletID, rawPub, + ourAddr, args.ToAddr, + sendAdaLov, args.Asset, + minChangeLovelace, + ) + if err == nil { + fmt.Println(submittedHash) + fmt.Println("explorer:", "https://preprod.cardanoscan.io/transaction/"+submittedHash) + return + } + logger.Info("Submit attempt failed", "attempt", attempt, "retryable", retryableErr, "error", err.Error()) + if !retryableErr || attempt == maxAttempts { + logger.Fatal("submit failed", err) + } + logger.Info("Rebuilding tx after failure", "next_attempt", attempt+1) + } +} + +func parseTokenArgsOrFatal() tokenArgs { + var out tokenArgs + out.Ada = 0 // Default to 0, will be auto-calculated + + var tokenSpec string + for i := 1; i < len(os.Args); i++ { + switch os.Args[i] { + case "--wallet-id": + i++ + out.WalletID = os.Args[i] + case "--to": + i++ + out.ToAddr = os.Args[i] + case "--token": + i++ + tokenSpec = os.Args[i] + case "--ada": + i++ + _, _ = fmt.Sscanf(os.Args[i], "%f", &out.Ada) + } + } + if out.WalletID == "" || out.ToAddr == "" || tokenSpec == "" { + logger.Fatal("usage: sign_tx_token --wallet-id --to --token [--ada 1.0]", nil) + } + asset, err := parseCardanoAssetArg(tokenSpec) + if err != nil { + logger.Fatal("invalid --token", err) + } + out.Asset = asset + return out +} + +func buildSignSubmitTokenOnce( + ctx context.Context, + bfCfg bfConfig, + params *protocolParams, + mpcClient client.MPCClient, + signResultCh <-chan event.SigningResultEvent, + submitURL string, + walletID string, + rawPub []byte, + ourAddr string, + toAddr string, + toAdaLovelace uint64, + token cardanoAsset, + minChangeLovelace uint64, +) (submittedHash string, retryable bool, err error) { + // 1) Fetch UTxOs + utxos, err := fetchAllUtxosOnce(ctx, bfCfg, ourAddr) + if err != nil { + return "", true, err + } + if len(utxos) == 0 { + return "", false, errors.New("no UTxO at address") + } + + // 2) TTL + currentSlot, err := fetchCurrentSlot(ctx, bfCfg) + if err != nil { + return "", true, err + } + ttlSlot := currentSlot + bfCfg.TTLSeconds + logger.Info("Using TTL", "current_slot", currentSlot, "ttl_slot", ttlSlot) + + // 3) Coin selection & min-ADA calculation (multi-asset) + const feeUpperBound uint64 = 900_000 + tokenUnit, err := assetUnitFromPolicyAndName(token.PolicyIDHex, token.AssetNameHex) + if err != nil { + return "", false, fmt.Errorf("invalid token spec: %w", err) + } + + // 4) Ensure receiver output has enough ADA (min-UTxO) BEFORE selecting ADA-only inputs + // NOTE: --ada is optional; we treat it as a *minimum* (can be 0). + minToAda, err := estimateMinAdaForOutput(params, toAddr, 0, []cardanoAsset{token}) + if err != nil || minToAda == 0 { + // Fallback to minChangeLovelace if calculation fails + minToAda = minChangeLovelace + } + if toAdaLovelace < minToAda { + logger.Info("Auto-bumping receiver ADA to satisfy min-UTxO", "min_required", minToAda, "requested", toAdaLovelace) + toAdaLovelace = minToAda + } + + // Find UTxOs with the target token, and other UTxOs for ADA + tokenUtxos := make([]simpleUtxo, 0) + adaOnlyUtxos := make([]simpleUtxo, 0) + for _, u := range utxos { + if u.Assets[tokenUnit] > 0 { + tokenUtxos = append(tokenUtxos, u) + } + // Any UTxO that does NOT contain the target token can be used to top up lovelace for fees/min-ADA, + // not only "lovelace-only" ones. This fixes the case where the wallet has ADA but it's sitting in + // UTxOs that also contain other tokens. + if u.Assets[tokenUnit] == 0 { + adaOnlyUtxos = append(adaOnlyUtxos, u) + } + } + sort.Slice(tokenUtxos, func(i, j int) bool { return tokenUtxos[i].Assets[tokenUnit] > tokenUtxos[j].Assets[tokenUnit] }) + sort.Slice(adaOnlyUtxos, func(i, j int) bool { return adaOnlyUtxos[i].Lovelace > adaOnlyUtxos[j].Lovelace }) + + // Select token inputs first + inputs := make([]txInput, 0) + totalInputAssets := make(map[string]uint64) + for _, u := range tokenUtxos { + if totalInputAssets[tokenUnit] >= token.Quantity { + break // we have enough of the target token + } + inputs = append(inputs, txInput{TxHashHex: u.TxHash, TxIndex: u.TxIndex}) + sumAssetsMaps(totalInputAssets, u.Assets) + } + if totalInputAssets[tokenUnit] < token.Quantity { + return "", false, fmt.Errorf("insufficient token balance for %s: have %d, need %d", tokenUnit, totalInputAssets[tokenUnit], token.Quantity) + } + + // Add ADA-only UTxOs if needed for fee + receiver min-ADA + change min-ADA + neededLovelace := toAdaLovelace + feeUpperBound + minChangeLovelace + if totalInputAssets["lovelace"] < neededLovelace { + for _, u := range adaOnlyUtxos { + inputs = append(inputs, txInput{TxHashHex: u.TxHash, TxIndex: u.TxIndex}) + sumAssetsMaps(totalInputAssets, u.Assets) + if totalInputAssets["lovelace"] >= neededLovelace { + break + } + } + } + logger.Info( + "Selected UTxOs", + "count", len(inputs), + "total_input_lovelace", totalInputAssets["lovelace"], + "total_input_token", totalInputAssets[tokenUnit], + "needed_lovelace_upper_bound", neededLovelace, + ) + + // 5) Fee & Change Calculation + dummySig := make([]byte, 64) + var feeLovelace uint64 + var txBodyCbor []byte + + outputAssets := map[string]uint64{ + "lovelace": toAdaLovelace, + tokenUnit: token.Quantity, + } + + for iter := 0; iter < 3; iter++ { + // Calculate change based on current fee estimate + changeAssets := make(map[string]uint64) + sumAssetsMaps(changeAssets, totalInputAssets) + + // Subtract outputs + if err := subAssetsMaps(changeAssets, outputAssets); err != nil { + return "", false, fmt.Errorf("value conservation error (outputs): %w", err) + } + + // Subtract fee + if changeAssets["lovelace"] < feeLovelace { + return "", false, errors.New("not enough lovelace for fee") + } + changeAssets["lovelace"] -= feeLovelace + + // Handle dust change + changeLovelace := changeAssets["lovelace"] + if changeLovelace > 0 && changeLovelace < minChangeLovelace { + feeLovelace += changeLovelace + changeAssets["lovelace"] = 0 + } + if changeAssets["lovelace"] == 0 { + delete(changeAssets, "lovelace") + } + + changeAssetsSlice, err := assetsMapToCardanoAssets(changeAssets) + if err != nil { + return "", false, fmt.Errorf("failed to convert change map to slice: %w", err) + } + + body, err := buildTxBodyCBORMultiAsset( + inputs, + toAddr, toAdaLovelace, []cardanoAsset{token}, + ourAddr, changeAssets["lovelace"], changeAssetsSlice, + feeLovelace, + ttlSlot, + ) + if err != nil { + return "", false, err + } + dummySigned, err := buildSignedTxCBOR(body, rawPub, dummySig) + if err != nil { + return "", false, err + } + size := len(dummySigned) + newFee := uint64(params.MinFeeA*size + params.MinFeeB) + logger.Info("Calculated fee", "iter", iter, "fee_lovelace", newFee, "signed_tx_size_bytes", size) + if newFee == feeLovelace { + txBodyCbor = body + break + } + feeLovelace = newFee + txBodyCbor = body + } + if txBodyCbor == nil { + return "", false, errors.New("failed to build tx body") + } + + // 5) Tx hash + h := blake2b.Sum256(txBodyCbor) + txHash := h[:] + logger.Info("Prepared tx hash", "tx_hash_hex", hex.EncodeToString(txHash)) + + // 6) Sign + txID := uuid.New().String() + if err := mpcClient.SignTransaction(&types.SignTxMessage{ + KeyType: types.KeyTypeEd25519, + WalletID: walletID, + NetworkInternalCode: "cardano-testnet", + TxID: txID, + Tx: txHash, + }); err != nil { + return "", true, err + } + logger.Info("SignTransaction sent", "txID", txID) + + var res event.SigningResultEvent + select { + case res = <-signResultCh: + if res.ResultType == event.ResultTypeError { + return "", false, errors.New("signing failed: "+res.ErrorReason) + } + case <-time.After(60 * time.Second): + return "", true, errors.New("timeout waiting for signing result") + } + + // 7) Submit + signedTxCbor, err := buildSignedTxCBOR(txBodyCbor, rawPub, res.Signature) + if err != nil { + return "", false, err + } + respBody, status, err := blockfrostPOSTCBORWithRetry(ctx, bfCfg.ProjectID, submitURL, signedTxCbor) + if err != nil { + return "", true, err + } + if status < 200 || status >= 300 { + msg := prettyJSON(respBody) + retryable = strings.Contains(msg, "BadInputsUTxO") || strings.Contains(msg, "InvalidWitnessesUTXOW") || strings.Contains(msg, "ValueNotConservedUTxO") || strings.Contains(msg, "OutsideValidityIntervalUTxO") + return "", retryable, fmt.Errorf("submit HTTP %d: %s", status, msg) + } + _ = json.Unmarshal(respBody, &submittedHash) + if submittedHash == "" { + submittedHash = strings.TrimSpace(string(respBody)) + } + return submittedHash, false, nil +} + diff --git a/go.mod b/go.mod index cf8dc21..44f80c3 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,13 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.18.8 github.com/aws/aws-sdk-go-v2/service/kms v1.45.0 github.com/bnb-chain/tss-lib/v2 v2.0.2 - github.com/btcsuite/btcd v0.24.2 - github.com/btcsuite/btcd/btcec/v2 v2.3.2 + github.com/btcsuite/btcd v0.25.0 + github.com/btcsuite/btcd/btcec/v2 v2.3.5 github.com/btcsuite/btcutil v1.0.2 + github.com/cosmos/btcutil v1.0.5 github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 - github.com/dgraph-io/badger/v4 v4.7.0 + github.com/dgraph-io/badger/v4 v4.9.0 + github.com/fxamacker/cbor/v2 v2.6.0 github.com/google/uuid v1.6.0 github.com/hashicorp/consul/api v1.32.1 github.com/mitchellh/mapstructure v1.5.0 @@ -23,8 +25,8 @@ require ( github.com/spf13/viper v1.18.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v3 v3.3.2 - golang.org/x/crypto v0.37.0 - golang.org/x/term v0.31.0 + golang.org/x/crypto v0.41.0 + golang.org/x/term v0.34.0 ) require ( @@ -49,7 +51,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect @@ -86,20 +88,22 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.9.0 // indirect go.uber.org/zap v1.21.0 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.24.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 697a3ea..c12a3d0 100644 --- a/go.sum +++ b/go.sum @@ -56,12 +56,13 @@ github.com/binance-chain/edwards25519 v0.0.0-20200305024217-f36fc4b53d43/go.mod github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= -github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= -github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= +github.com/btcsuite/btcd v0.25.0 h1:JPbjwvHGpSywBRuorFFqTjaVP4y6Qw69XJ1nQ6MyWJM= +github.com/btcsuite/btcd v0.25.0/go.mod h1:qbPE+pEiR9643E1s1xu57awsRhlCIm1ZIi6FfeRA4KE= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= -github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU= +github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ= github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= @@ -85,6 +86,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cosmos/btcutil v1.0.5 h1:t+ZFcX77LpKtDBhjucvnOH8C2l2ioGsBNEQ3jef8xFk= +github.com/cosmos/btcutil v1.0.5/go.mod h1:IyB7iuqZMJlthe2tkIFL33xPyzbFYP0XVdS8P5lUPis= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -97,8 +100,8 @@ github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgW github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= -github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y= -github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA= +github.com/dgraph-io/badger/v4 v4.9.0 h1:tpqWb0NewSrCYqTvywbcXOhQdWcqephkVkbBmaaqHzc= +github.com/dgraph-io/badger/v4 v4.9.0/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= @@ -116,6 +119,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= +github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/fystack/tss-lib/v2 v2.0.3 h1:A0HGL5GDPpKbNW+0ZXgv1Ri3+ks88AvxTS7OK40gnUY= github.com/fystack/tss-lib/v2 v2.0.3/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -123,8 +128,8 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -365,18 +370,20 @@ github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqri github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= @@ -402,8 +409,8 @@ golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -431,8 +438,8 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -479,15 +486,15 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -496,8 +503,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= @@ -522,8 +529,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -540,8 +547,9 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=