Instructions
We have been creating the 2-of-2 TSS wallet utilizing Flow Go SDK. We have successfully created the its feature.
The code which enabled the 2-of-2 MPC-TSS:
# Wallet(share 1) Part
func handleCreateAccount(c *gin.Context) {
ctx := context.Background()
// Using the global flowClient if you have it initialized in main,
// otherwise creating a local one for this request.
flowClient, err := grpc.NewClient(grpc.TestnetHost)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to connect to Flow"})
return
}
// Get Signer's Public Key
signerURL := "https://blsqui-signer.net/api/tss/generate-key"
resp, err := http.Post(signerURL, "application/json", bytes.NewBuffer([]byte("{}")))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Signer node unreachable"})
return
}
defer resp.Body.Close()
var signerResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&signerResp)
rawKey, ok := signerResp["signer_public_key"].(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Signer response missing key"})
return
}
signerPubKeyHex := rawKey
if len(rawKey) > 2 && rawKey[:2] == "0x" {
signerPubKeyHex = rawKey[2:]
}
signerBytes, _ := hex.DecodeString(signerPubKeyHex)
signerPubKey, _ := crypto.DecodePublicKey(crypto.ECDSA_P256, signerBytes)
// --- Prepare Keys (500/500 Weight) ---
signerAccKey := flow.NewAccountKey().
SetPublicKey(signerPubKey).
SetHashAlgo(crypto.SHA3_256).
SetWeight(500)
// Using a deterministic seed for this test, but in prod this would be per-user
walletSeed := []byte("a-secure-local-wallet-seed-32-bytes")
walletPrivKey, _ := crypto.GeneratePrivateKey(crypto.ECDSA_P256, walletSeed)
walletAccKey := flow.NewAccountKey().
SetPublicKey(walletPrivKey.PublicKey()).
SetHashAlgo(crypto.SHA3_256).
SetWeight(500)
// Fetch Payer Details
payerAddr := flow.HexToAddress(masterAddress)
payerAccount, _ := flowClient.GetAccount(ctx, payerAddr)
payerKey := payerAccount.Keys[0]
// Build Account Creation Transaction
tx, _ := templates.CreateAccount([]*flow.AccountKey{signerAccKey, walletAccKey}, nil, payerAddr)
latestBlock, _ := flowClient.GetLatestBlock(ctx, true)
tx.SetReferenceBlockID(latestBlock.ID)
tx.SetProposalKey(payerAddr, payerKey.Index, payerKey.SequenceNumber)
tx.SetPayer(payerAddr)
// Sign with Master Payer Key
masterPrivKey := os.Getenv("SIGNER_PRIV_KEY")
if masterPrivKey == "" {
fmt.Println("[SIGNER ERROR] SIGNER_PRIV_KEY not set in environment!")
c.JSON(500, gin.H{"error": "Server configuration error"})
return
}
masterBytes, _ := hex.DecodeString(masterPrivKey)
masterSK, _ := crypto.DecodePrivateKey(crypto.ECDSA_P256, masterBytes)
masterSigner, _ := crypto.NewNaiveSigner(masterSK, crypto.SHA3_256)
err = tx.SignEnvelope(payerAddr, payerKey.Index, masterSigner)
if err != nil {
c.JSON(500, gin.H{"error": "Signing failed"})
return
}
// Broadcast
err = flowClient.SendTransaction(ctx, *tx)
if err != nil {
c.JSON(500, gin.H{"error": "Broadcast failed"})
return
}
// Polling for Result (The "Wait" phase)
var txResult *flow.TransactionResult
for i := 0; i < 25; i++ { // Increased to 25s for Testnet stability
txResult, _ = flowClient.GetTransactionResult(ctx, tx.ID())
if txResult != nil && txResult.Status == flow.TransactionStatusSealed {
break
}
time.Sleep(1 * time.Second)
}
if txResult == nil || txResult.Status != flow.TransactionStatusSealed {
c.JSON(202, gin.H{"status": "pending", "tx_id": tx.ID().Hex(), "msg": "Timeout polling result"})
return
}
// Parse the New Address from Events
var newAddress string
for _, event := range txResult.Events {
if event.Type == flow.EventAccountCreated {
// Helper to get the address from the event payload
accountCreatedEvent := flow.AccountCreatedEvent(event)
newAddress = accountCreatedEvent.Address().Hex()
break
}
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"tx_id": tx.ID().Hex(),
"new_address": newAddress,
"details": "Account created with 500/500 TSS weight split.",
})
}
Signer Node(share 2) Part (/generate-key):
func handleGenerateKey(c *gin.Context) {
seed := []byte("pioneer-seed-must-be-32-bytes-long-vault-key-1")
sk, err := crypto.GeneratePrivateKey(crypto.ECDSA_P256, seed)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Key generation failed"})
return
}
share := KeyShare{
ShareID: "node-alpha",
SecretShare: sk.String(),
PublicKeyPoint: sk.PublicKey().String(),
}
fileData, _ := json.MarshalIndent(share, "", " ")
_ = os.WriteFile("shares.txt", fileData, 0644)
c.JSON(http.StatusOK, gin.H{
"status": "real_key_persisted",
"signer_public_key": share.PublicKeyPoint,
})
}
Problem
Actually we are stacking in sign part. How we are battling with error codes are written in this blog.
https://medium.com/@tickets.on.flow/building-blsqui-solving-the-multi-sig-duplicate-signature-ghost-on-flow-30b57ca31ca8
Like the story in this article, I am using the Gemini to implement the sign part. But it never succeeded. (With much of fighting with Gemini, I am used to write English.) So the experts help is needed.
The code for it:
s1(Wallet) URL: blsqui.net:
As you can see, our code is almost perfect, then we now just suspecting the domain tag. So that we manually attach domain tag to payload in Signer(s2) code. And it didn't work to solve Error 1008, then we attached domain tag to payload in Wallet(s1) code. But 1008 error didn't solved. Now we had to give up further surgery of code, and ask for expert's help.
func handleUserAction(c *gin.Context) {
signerIndexStr := os.Getenv("SIGNER_KEY_INDEX")
signerIndex, _ := strconv.Atoi(signerIndexStr)
var req struct {
Address string `json:"address"`
Target string `json:"target"`
Amount string `json:"amount"`
DebugTag string `json:"debug_tag"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
userAddress := flow.HexToAddress(req.Address)
payerAddr := flow.HexToAddress(masterAddress)
tx := flow.NewTransaction().
SetScript([]byte("transaction { prepare(signer: &Account) { log(\"Blsqui Signature Verified\") } }"))
tx.AddAuthorizer(userAddress)
// Fetch Fresh Sequence Number
userAcc, err := flowClient.GetAccount(ctx, userAddress)
if err != nil {
c.JSON(500, gin.H{"error": "Could not fetch user account"})
return
}
tx.SetProposalKey(userAddress, 0, userAcc.Keys[0].SequenceNumber)
tx.SetPayer(payerAddr)
latestBlock, _ := flowClient.GetLatestBlock(ctx, true)
tx.SetReferenceBlockID(latestBlock.ID)
walletSeed := []byte("a-secure-local-wallet-seed-32-bytes")
walletSK, _ := crypto.GeneratePrivateKey(crypto.ECDSA_P256, walletSeed)
walletSigner, _ := crypto.NewNaiveSigner(walletSK, crypto.SHA3_256)
cleanPayload := tx.PayloadMessage()
fmt.Printf("[WALLET] Sending Clean Payload: %x\n", cleanPayload)
remoteSig, err := requestSignerPart(cleanPayload)
if err != nil {
c.JSON(502, gin.H{"error": "Signer VPS failed", "details": err.Error()})
return
}
// We must use the same Domain Tag as the Signer VPS
payloadTag := []byte("FLOW-V0.0-transaction-payload")
for len(payloadTag) < 32 {
payloadTag = append(payloadTag, 0)
}
taggedPayload := append(payloadTag, cleanPayload...)
localSig, err := walletSigner.Sign(taggedPayload)
if err != nil {
c.JSON(500, gin.H{"error": "Local signing failed"})
return
}
tx.AddPayloadSignature(userAddress, 0, remoteSig) // Remote (Proposal Key)
tx.AddPayloadSignature(userAddress, 1, localSig) // Local (Wallet Key)
// err = tx.SignPayload(userAddress, 1, walletSigner)
// if err != nil { fmt.Printf("Wallet Sign Error: %v\n", err) }
fmt.Printf("\n[FORENSIC] --- SIGNING PHASE START ---\n")
fmt.Printf("[FORENSIC] Remote Sig (Signer VPS): %x\n", remoteSig)
fmt.Printf("[FORENSIC] Local Sig (Wallet VPS): %x\n", localSig)
fmt.Printf("[FORENSIC] User Address: %s | Key Index: 0\n", userAddress.Hex())
fmt.Printf("[FORENSIC] Signer Key Index (from Env): %d\n", signerIndex)
fmt.Printf("[FORENSIC] Payer Address: %s | Index: 0\n", payerAddr.Hex())
// Apply the remote signature
// tx.AddPayloadSignature(userAddress, uint32(signerIndex), remoteSig)
masterPrivKey := os.Getenv("SIGNER_PRIV_KEY")
if masterPrivKey == "" {
c.JSON(500, gin.H{"error": "SIGNER_PRIV_KEY not set"})
return
}
masterBytes, _ := hex.DecodeString(masterPrivKey)
masterSK, _ := crypto.DecodePrivateKey(crypto.ECDSA_P256, masterBytes)
masterSigner, _ := crypto.NewNaiveSigner(masterSK, crypto.SHA3_256)
// Sign the Envelope
tx.SignEnvelope(payerAddr, 0, masterSigner)
fmt.Printf("[FORENSIC] Final Tx ID: %s\n", tx.ID().Hex())
fmt.Printf("[FORENSIC] --- SIGNING PHASE COMPLETE ---\n\n")
// BROADCAST
err = flowClient.SendTransaction(ctx, *tx)
if err != nil {
fmt.Printf("[BROADCAST ERROR] %v\n", err) // Log the full error to VPS console
c.JSON(500, gin.H{"error": "Broadcast failed", "details": err.Error()})
return
}
c.JSON(200, gin.H{"status": "success", "tx_id": tx.ID().Hex()})
}
func requestSignerPart(payload []byte) ([]byte, error) {
signerURL := "https://blsqui-signer.net/api/tss/sign"
reqBody, _ := json.Marshal(map[string]string{
"tx_hash": hex.EncodeToString(payload),
"debug_tag": "v2-battle-final",
})
resp, err := http.Post(signerURL, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
fmt.Printf("[DEBUG] Network Error to Signer: %v\n", err)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("signer returned status: %d", resp.StatusCode)
}
var res map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
sigHex, ok := res["partial_signature"].(string)
if !ok {
return nil, fmt.Errorf("signature missing or not a string")
}
sigHex = strings.TrimPrefix(sigHex, "0x")
remoteSigBytes, err := hex.DecodeString(sigHex)
if err != nil {
return nil, fmt.Errorf("failed to decode signer hex: %v", err)
}
return remoteSigBytes, nil
}
s2(Signer Node) URL: blsqui-signer.net:
func handleSignRequest(c *gin.Context) {
var req struct {
TxHash string `json:"tx_hash"`
DebugTag string `json:"debug_tag"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fmt.Printf("[SIGNER ERROR] Malformed JSON: %v\n", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return
}
fmt.Println("--- TSS SIGNING CEREMONY START ---")
fmt.Printf("[SIGNER] Received Tag: %s\n", req.DebugTag)
payloadBytes, err := hex.DecodeString(req.TxHash)
if err != nil {
fmt.Printf("[SIGNER ERROR] Failed to decode hex: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Hex decode failed"})
return
}
fmt.Printf("[SIGNER] Received Payload: %s (Length: %d bytes)\n", req.TxHash, len(payloadBytes))
envKeyHex := os.Getenv("SIGNER_PRIV_KEY")
if envKeyHex == "" {
fmt.Println("[SIGNER ERROR] SIGNER_PRIV_KEY not set in environment!")
c.JSON(500, gin.H{"error": "Server configuration error"})
return
}
seed, err := hex.DecodeString(envKeyHex)
if err != nil {
fmt.Printf("[SIGNER ERROR] Hex Decode Failure: %v\n", err)
c.JSON(500, gin.H{"error": "Invalid key format in environment"})
return
}
signerSK, err := crypto.DecodePrivateKey(crypto.ECDSA_P256, seed)
if err != nil {
c.JSON(500, gin.H{"error": "Internal Key Error"})
return
}
hasher, err := crypto.NewHasher(crypto.SHA3_256)
if err != nil {
c.JSON(500, gin.H{"error": "Hasher setup failed"})
return
}
// We need to prepend the Transaction Domain Tag so Flow recognizes the signature
payloadTag := []byte("FLOW-V0.0-transaction-payload")
// Pad with null bytes to reach exactly 32 bytes
for len(payloadTag) < 32 {
payloadTag = append(payloadTag, 0)
}
// 2. Combine the Tag and the Payload
taggedPayload := append(payloadTag, payloadBytes...)
// Sign the tagged payload
signature, err := signerSK.Sign(taggedPayload, hasher)
if err != nil {
fmt.Printf("[SIGNER ERROR] Signing Operation Failed: %v\n", err)
c.JSON(500, gin.H{"error": "Signing failed"})
return
}
if err != nil {
fmt.Printf("[SIGNER ERROR] Raw Signing Operation Failed: %v\n", err)
c.JSON(500, gin.H{"error": "Signing failed"})
return
}
sigString := hex.EncodeToString(signature)
fmt.Printf("[SIGNER] Partial Signature Generated: %s\n", sigString)
fmt.Println("--- TSS SIGNING CEREMONY COMPLETE ---")
c.JSON(http.StatusOK, gin.H{
"partial_signature": sigString,
"signer_index": 0,
"status": "signed",
})
}
Actually these are the all code what we wrote for account creation and signing.
The gin code is below
Wallet(blsqui.net):
api := r.Group("/api")
{
// --- Account Management ---
// Triggers the handshake with the Signer to create a Flow account
api.POST("/account/create", handleCreateAccount)
// Retrieves the user's Flow address and balance from Supabase/Chain
api.GET("/account/info", handleGetAccountInfo)
// --- Transaction Management ---
// Triggers the TSS signing ceremony
api.POST("/account/transfer", handleUserAction)
// --- Vault/Database Integration ---
// Syncs metadata with your Supabase instance
api.POST("/vault/sync", handleVaultSync) // Not implemented
}
Signer(blsqui-signer.net):
tss := r.Group("/api/tss")
{
// 1. DKG: Generate a new key share for a new account
tss.POST("/generate-key", handleGenerateKey)
// 2. Signing: Generate a partial signature
tss.POST("/sign", handleSignRequest)
// 3. Health Check: (Good for Manager monitoring)
tss.GET("/status", handleStatus)
}
Steps to Reproduce
You can create the 2-of-2 TSS account by this command:
curl -X POST https://blsqui.net/api/account/create \
-H "Content-Type: application/json" \
-d '{}'
output sample:
{"details":"Account created with 500/500 TSS weight split.","new_address":"bb134d6d607e55d1","status":"success","tx_id":"9199929762579e68d3d849e993caebbb6750d56a35933238d55d2be490ccd37e"}
And you can check its key information with this command:
curl "https://blsqui.net/api/account/info?address=5d0cdb3f9e6f2543"
output sample:
{"address":"5d0cdb3f9e6f2543","balance":100000,"keys":[
{"index":0,"public_key":"0e7c485a1e6f9e1faa3341334262bfd566cd5798ac9adb9b55f1e44e91e0c1418c2015911476b6c046edc917dbef1a8665bc2a02b8f94b8275f3d34c4708e3bc","weight":500},
{"index":1,"public_key":"942f822336e88e8bb6b2355948fefe5bfdf8004800c43053dceaea90b6d2f24c8074275a30140d4aaf070ac3869be86b87be60da6cea8c051e3f5cec3555acc3","weight":500}
]}
The problem part. This tx result become 1008:
curl -X POST https://blsqui.net/api/account/transfer \
-H "Content-Type: application/json" \
-d '{
"address": "420aa31a24052146",
"target": "0x5bb9b951fb690047",
"amount": "0.1",
"debug_tag": "v2-new-account-test"
}'
Acceptance Criteria
I asked this part to Gemini. Gemini suggested these.
[ ] A transaction with a Proposal Key signed by a remote signer (via AddPayloadSignature) is successfully verified by the Access Node.
[ ] Documentation or a code example is provided showing the correct way to manually hash and tag a payload for remote signing.
[ ] (Optional) A helper function is added to the SDK to handle the 32-byte domain tagging automatically for AddPayloadSignature.
Context
Actually we already posted the press release of this. And it was featured by the most powerful financial newspaper web site.
The press release was written in English followed by Japanese. The website is this.
https://prtimes.jp/main/html/rd/p/000000008.000104644.html
(Sadly, English line were broken though)
The landing webpage is this.
https://blsqui.net/
As like written here, this was radared by NIKKEI COMPASS, the most powerful financial media.
We must build this. I appreciate any helpful comment.
And you can see what we are battling today and yesterday here: https://medium.com/@tickets.on.flow/building-blsqui-solving-the-multi-sig-duplicate-signature-ghost-on-flow-30b57ca31ca8
Thank you in advance.
Instructions
We have been creating the 2-of-2 TSS wallet utilizing Flow Go SDK. We have successfully created the its feature.
The code which enabled the 2-of-2 MPC-TSS:
Problem
Actually we are stacking in sign part. How we are battling with error codes are written in this blog.
https://medium.com/@tickets.on.flow/building-blsqui-solving-the-multi-sig-duplicate-signature-ghost-on-flow-30b57ca31ca8
Like the story in this article, I am using the Gemini to implement the sign part. But it never succeeded. (With much of fighting with Gemini, I am used to write English.) So the experts help is needed.
The code for it:
s1(Wallet) URL: blsqui.net:
As you can see, our code is almost perfect, then we now just suspecting the domain tag. So that we manually attach domain tag to payload in Signer(s2) code. And it didn't work to solve Error 1008, then we attached domain tag to payload in Wallet(s1) code. But 1008 error didn't solved. Now we had to give up further surgery of code, and ask for expert's help.
s2(Signer Node) URL: blsqui-signer.net:
Actually these are the all code what we wrote for account creation and signing.
The gin code is below
Steps to Reproduce
You can create the 2-of-2 TSS account by this command:
And you can check its key information with this command:
The problem part. This tx result become 1008:
Acceptance Criteria
I asked this part to Gemini. Gemini suggested these.
[ ] A transaction with a Proposal Key signed by a remote signer (via AddPayloadSignature) is successfully verified by the Access Node.
[ ] Documentation or a code example is provided showing the correct way to manually hash and tag a payload for remote signing.
[ ] (Optional) A helper function is added to the SDK to handle the 32-byte domain tagging automatically for AddPayloadSignature.
Context
Actually we already posted the press release of this. And it was featured by the most powerful financial newspaper web site.
The press release was written in English followed by Japanese. The website is this.
https://prtimes.jp/main/html/rd/p/000000008.000104644.html
(Sadly, English line were broken though)
The landing webpage is this.
https://blsqui.net/
As like written here, this was radared by NIKKEI COMPASS, the most powerful financial media.
We must build this. I appreciate any helpful comment.
And you can see what we are battling today and yesterday here: https://medium.com/@tickets.on.flow/building-blsqui-solving-the-multi-sig-duplicate-signature-ghost-on-flow-30b57ca31ca8
Thank you in advance.