Skip to content

Commit

Permalink
Merge pull request #33 from elnosh/nut11
Browse files Browse the repository at this point in the history
wallet: NUT-11 P2PK
  • Loading branch information
elnosh committed Jul 9, 2024
2 parents abd55b0 + a75d5a6 commit c2922c8
Show file tree
Hide file tree
Showing 11 changed files with 897 additions and 95 deletions.
90 changes: 77 additions & 13 deletions cashu/cashu.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,52 @@ package cashu

import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"

"github.com/decred/dcrd/dcrec/secp256k1/v4"
)

type SecretKind int

const (
Random SecretKind = iota
P2PK
)

// Cashu BlindedMessage. See https://github.com/cashubtc/nuts/blob/main/00.md#blindedmessage
type BlindedMessage struct {
Amount uint64 `json:"amount"`
B_ string `json:"B_"`
Id string `json:"id"`

// including Witness field for now to avoid throwing error when parsing json
// from clients that include this field even when mint does not support it.
Amount uint64 `json:"amount"`
B_ string `json:"B_"`
Id string `json:"id"`
Witness string `json:"witness,omitempty"`
}

func NewBlindedMessage(id string, amount uint64, B_ *secp256k1.PublicKey) BlindedMessage {
B_str := hex.EncodeToString(B_.SerializeCompressed())
return BlindedMessage{Amount: amount, B_: B_str, Id: id}
}

func SortBlindedMessages(blindedMessages BlindedMessages, secrets []string, rs []*secp256k1.PrivateKey) {
// sort messages, secrets and rs
for i := 0; i < len(blindedMessages)-1; i++ {
for j := i + 1; j < len(blindedMessages); j++ {
if blindedMessages[i].Amount > blindedMessages[j].Amount {
// Swap blinded messages
blindedMessages[i], blindedMessages[j] = blindedMessages[j], blindedMessages[i]

// Swap secrets
secrets[i], secrets[j] = secrets[j], secrets[i]

// Swap rs
rs[i], rs[j] = rs[j], rs[i]
}
}
}
}

type BlindedMessages []BlindedMessage

func (bm BlindedMessages) Amount() uint64 {
Expand All @@ -41,16 +71,50 @@ type BlindedSignatures []BlindedSignature

// Cashu Proof. See https://github.com/cashubtc/nuts/blob/main/00.md#proof
type Proof struct {
Amount uint64 `json:"amount"`
Id string `json:"id"`
Secret string `json:"secret"`
C string `json:"C"`

// including Witness field for now to avoid throwing error when parsing json
// from clients that include this field even when mint does not support it.
Amount uint64 `json:"amount"`
Id string `json:"id"`
Secret string `json:"secret"`
C string `json:"C"`
Witness string `json:"witness,omitempty"`
}

func (p Proof) IsSecretP2PK() bool {
return p.SecretType() == P2PK
}

func (p Proof) SecretType() SecretKind {
var rawJsonSecret []json.RawMessage
// if not valid json, assume it is random secret
if err := json.Unmarshal([]byte(p.Secret), &rawJsonSecret); err != nil {
return Random
}

// Well-known secret should have a length of at least 2
if len(rawJsonSecret) < 2 {
return Random
}

var kind string
if err := json.Unmarshal(rawJsonSecret[0], &kind); err != nil {
return Random
}

if kind == "P2PK" {
return P2PK
}

return Random
}

func (kind SecretKind) String() string {
switch kind {
case P2PK:
return "P2PK"
default:
return "random"
}
}

type Proofs []Proof

// Amount returns the total amount from
Expand Down
38 changes: 38 additions & 0 deletions cashu/cashu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,41 @@ func TestTokenToString(t *testing.T) {
}
}
}

func TestSecretType(t *testing.T) {
tests := []struct {
proof Proof
expectedKind SecretKind
expectedIsP2PK bool
}{
{
proof: Proof{Secret: `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]`},
expectedKind: P2PK,
expectedIsP2PK: true,
},

{
proof: Proof{Secret: `["DIFFERENT", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[]}]`},
expectedKind: Random,
expectedIsP2PK: false,
},

{
proof: Proof{Secret: `someranadomsecret`},
expectedKind: Random,
expectedIsP2PK: false,
},
}

for _, test := range tests {
kind := test.proof.SecretType()
if kind != test.expectedKind {
t.Fatalf("expected '%v' but got '%v' instead", test.expectedKind.String(), kind.String())
}

isP2PK := test.proof.IsSecretP2PK()
if isP2PK != test.expectedIsP2PK {
t.Fatalf("expected '%v' but got '%v' instead", test.expectedIsP2PK, isP2PK)
}
}
}
53 changes: 53 additions & 0 deletions cashu/nuts/nut10/nut10.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package nut10

import (
"encoding/json"
"errors"
"fmt"

"github.com/elnosh/gonuts/cashu"
)

type WellKnownSecret struct {
Nonce string `json:"nonce"`
Data string `json:"data"`
Tags [][]string `json:"tags"`
}

// SerializeSecret returns the json string to be put in the secret field of a proof
func SerializeSecret(kind cashu.SecretKind, secretData WellKnownSecret) (string, error) {
jsonSecret, err := json.Marshal(secretData)
if err != nil {
return "", err
}

secretKind := kind.String()
secret := fmt.Sprintf("[\"%s\", %v]", secretKind, string(jsonSecret))
return secret, nil
}

// DeserializeSecret returns Well-known secret struct.
// It returns error if it's not valid according to NUT-10
func DeserializeSecret(secret string) (WellKnownSecret, error) {
var rawJsonSecret []json.RawMessage
if err := json.Unmarshal([]byte(secret), &rawJsonSecret); err != nil {
return WellKnownSecret{}, err
}

// Well-known secret should have a length of at least 2
if len(rawJsonSecret) < 2 {
return WellKnownSecret{}, errors.New("invalid secret: length < 2")
}

var kind string
if err := json.Unmarshal(rawJsonSecret[0], &kind); err != nil {
return WellKnownSecret{}, errors.New("invalid kind for secret")
}

var secretData WellKnownSecret
if err := json.Unmarshal(rawJsonSecret[1], &secretData); err != nil {
return WellKnownSecret{}, fmt.Errorf("invalid secret: %v", err)
}

return secretData, nil
}
54 changes: 54 additions & 0 deletions cashu/nuts/nut10/nut10_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package nut10

import (
"reflect"
"testing"

"github.com/elnosh/gonuts/cashu"
)

func TestSerializeSecret(t *testing.T) {
secretData := WellKnownSecret{
Nonce: "da62796403af76c80cd6ce9153ed3746",
Data: "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e",
Tags: [][]string{
{"sigflag", "SIG_ALL"},
},
}

serialized, err := SerializeSecret(cashu.P2PK, secretData)
if err != nil {
t.Fatalf("got unexpected error: %v", err)
}

expected := `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]`

if serialized != expected {
t.Fatalf("expected secret:\n%v\n\n but got:\n%v", expected, serialized)
}
}

func TestDeserializeSecret(t *testing.T) {
secret := `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]`
secretData, err := DeserializeSecret(secret)
if err != nil {
t.Fatalf("got unexpected error: %v", err)
}

expectedNonce := "da62796403af76c80cd6ce9153ed3746"
if secretData.Nonce != expectedNonce {
t.Fatalf("expected nonce '%v' but got '%v' instead", expectedNonce, secretData.Nonce)
}

expectedData := "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e"
if secretData.Data != expectedData {
t.Fatalf("expected data '%v' but got '%v' instead", expectedData, secretData.Data)
}

expectedTags := [][]string{
{"sigflag", "SIG_ALL"},
}
if !reflect.DeepEqual(secretData.Tags, expectedTags) {
t.Fatalf("expected tags '%v' but got '%v' instead", expectedTags, secretData.Tags)
}
}
129 changes: 129 additions & 0 deletions cashu/nuts/nut11/nut11.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package nut11

import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reflect"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/elnosh/gonuts/cashu"
"github.com/elnosh/gonuts/cashu/nuts/nut10"
)

type P2PKWitness struct {
Signatures []string `json:"signatures"`
}

// P2PKSecret returns a secret with a spending condition
// that will lock ecash to a public key
func P2PKSecret(pubkey string) (string, error) {
// generate random nonce
nonceBytes := make([]byte, 32)
_, err := rand.Read(nonceBytes)
if err != nil {
return "", err
}
nonce := hex.EncodeToString(nonceBytes)

secretData := nut10.WellKnownSecret{
Nonce: nonce,
Data: pubkey,
}

secret, err := nut10.SerializeSecret(cashu.P2PK, secretData)
if err != nil {
return "", err
}

return secret, nil
}

func AddSignatureToInputs(inputs cashu.Proofs, signingKey *btcec.PrivateKey) (cashu.Proofs, error) {
for i, proof := range inputs {
hash := sha256.Sum256([]byte(proof.Secret))
signature, err := schnorr.Sign(signingKey, hash[:])
if err != nil {
return nil, err
}
signatureBytes := signature.Serialize()

p2pkWitness := P2PKWitness{
Signatures: []string{hex.EncodeToString(signatureBytes)},
}

witness, err := json.Marshal(p2pkWitness)
if err != nil {
return nil, err
}
proof.Witness = string(witness)
inputs[i] = proof
}

return inputs, nil
}

func AddSignatureToOutputs(
outputs cashu.BlindedMessages,
signingKey *btcec.PrivateKey,
) (cashu.BlindedMessages, error) {
for i, output := range outputs {
msgToSign, err := hex.DecodeString(output.B_)
if err != nil {
return nil, err
}

hash := sha256.Sum256(msgToSign)
signature, err := schnorr.Sign(signingKey, hash[:])
if err != nil {
return nil, err
}
signatureBytes := signature.Serialize()

p2pkWitness := P2PKWitness{
Signatures: []string{hex.EncodeToString(signatureBytes)},
}

witness, err := json.Marshal(p2pkWitness)
if err != nil {
return nil, err
}
output.Witness = string(witness)
outputs[i] = output
}

return outputs, nil
}

func IsSigAll(secret nut10.WellKnownSecret) bool {
for _, tag := range secret.Tags {
if len(tag) == 2 {
if tag[0] == "sigflag" && tag[1] == "SIG_ALL" {
return true
}
}
}

return false
}

func CanSign(secret nut10.WellKnownSecret, key *btcec.PrivateKey) bool {
secretData, err := hex.DecodeString(secret.Data)
if err != nil {
return false
}

publicKey, err := secp256k1.ParsePubKey(secretData)
if err != nil {
return false
}

if reflect.DeepEqual(publicKey.SerializeCompressed(), key.PubKey().SerializeCompressed()) {
return true
}

return false
}
Loading

0 comments on commit c2922c8

Please sign in to comment.