diff --git a/README.md b/README.md index 1bf5803..8c0ab08 100644 --- a/README.md +++ b/README.md @@ -320,9 +320,21 @@ and can be up to the maximum script element size. `OP_NUM2BIN` and | Word | Opcode | Hex | Input | Output | Description | |------|--------|-----|-------|--------|-------------| -| OP_ECMULSCALARVERIFY | 227 | 0xe3 | k P Q | Nothing/fail | Verifies that Q = k*P where k is a 32-byte scalar, P is a compressed public key, and Q is a compressed public key. Fails if verification fails. | +| OP_ECADD | 224 | 0xe0 | x1 y1 x2 y2 curve_id | x3 y3 | Adds two affine points on the selected curve. Coordinates are Arkade BigNums; `(0, 0)` represents the point at infinity. Fails on unsupported `curve_id`, non-minimal BigNums, negative or out-of-field coordinates, and off-curve points. | +| OP_ECMUL | 225 | 0xe1 | x y k curve_id | x2 y2 | Multiplies an affine point by a scalar on the selected curve. `k = 0` returns the point at infinity. Fails on scalars `>=` the group order, off-curve points, and the other validation cases listed for OP_ECADD. | +| OP_ECPAIRING | 226 | 0xe2 | [g1_x g1_y g2_x_c1 g2_x_c0 g2_y_c1 g2_y_c0]... pair_count curve_id | bool | Checks whether the product of pairings is the identity in GT. Pushes canonical true (`0x01`) on success, canonical false (empty) on a valid non-identity product. `pair_count = 0` returns true. Pairing only works for `alt_bn128`; any other curve fails execution. Fails on non-minimal BigNums, negative or out-of-field coordinates, off-curve G1, off-curve G2, G2 outside the `alt_bn128` r-subgroup, negative `pair_count`, and `pair_count > 16`. | +| OP_ECMULSCALARVERIFY | 227 | 0xe3 | k P Q | Nothing/fail | Verifies that Q = k*P on secp256k1 where k is a 32-byte scalar, P is a compressed public key, and Q is a compressed public key. Fails if verification fails. | | OP_TWEAKVERIFY | 228 | 0xe4 | P k Q | Nothing/fail | Verifies that Q = P + k*G where P is a 32-byte X-only internal key, k is a 32-byte big-endian scalar, Q is a 33-byte compressed point, and G is the generator point. Fails if verification fails. | +#### Curve IDs + +| Curve ID | Curve | Operations | +|----------|-------|------------| +| 0 | `secp256k1` | addition, scalar multiplication | +| 1 | `secp256r1` (NIST P-256) | addition, scalar multiplication | +| 2 | `alt_bn128` / BN254 | addition, scalar multiplication, pairing | + + ### SHA256 Streaming Operations These opcodes allow incremental SHA256 hashing by maintaining hash state on the stack. diff --git a/go.mod b/go.mod index 6e609c7..b82e3e3 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/btcsuite/btcd/btcutil/psbt v1.1.9 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/btcsuite/btcwallet v0.16.10-0.20240718224643-db3a4a2543bd + github.com/consensys/gnark-crypto v0.19.2 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/meshapi/grpc-api-gateway v0.1.0 github.com/sirupsen/logrus v1.9.3 @@ -45,6 +46,7 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/arkade-os/arkd/pkg/kvdb v0.7.1-0.20260216152434-74a173c67a37 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect github.com/btcsuite/btcwallet/wallet/txauthor v1.3.4 // indirect github.com/btcsuite/btcwallet/wallet/txrules v1.2.1 // indirect diff --git a/go.sum b/go.sum index fef4cda..3443f88 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 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.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -95,6 +97,8 @@ github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q1 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= +github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -316,6 +320,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= diff --git a/pkg/arkade/ec_ops.go b/pkg/arkade/ec_ops.go new file mode 100644 index 0000000..d6ca0d2 --- /dev/null +++ b/pkg/arkade/ec_ops.go @@ -0,0 +1,516 @@ +package arkade + +import ( + "crypto/elliptic" + "errors" + "fmt" + "math/big" + + gnarkbn254 "github.com/consensys/gnark-crypto/ecc/bn254" + gnarkbn254fp "github.com/consensys/gnark-crypto/ecc/bn254/fp" + gnarkbn254fr "github.com/consensys/gnark-crypto/ecc/bn254/fr" + gnarksecp256k1 "github.com/consensys/gnark-crypto/ecc/secp256k1" + gnarksecp256k1fp "github.com/consensys/gnark-crypto/ecc/secp256k1/fp" + gnarksecp256k1fr "github.com/consensys/gnark-crypto/ecc/secp256k1/fr" + + "github.com/btcsuite/btcd/txscript" +) + +// Curve identifiers consumed by OP_ECADD, OP_ECMUL, and OP_ECPAIRING. Curve +// IDs are pushed on the stack as Arkade BigNums. +const ( + CurveSecp256k1 int64 = 0 + CurveSecp256r1 int64 = 1 + CurveAltBN128 int64 = 2 +) + +// maxECPairingCount is the maximum number of (G1, G2) pairs OP_ECPAIRING +// will process in a single call. Arkade Script has no gas model, so the +// CPU cost of a pairing-product check must be bounded deterministically. +const maxECPairingCount = 16 + +// alt_bn128 (G1, G2, pairing) and secp256k1 (G1) use gnark-crypto v0.19.2. +// secp256r1 / NIST P-256 is not in gnark-crypto, so it uses Go's standard +// crypto/elliptic.P256() — itself backed by crypto/internal/nistec and used +// in crypto/tls, crypto/ecdsa, and crypto/ecdh. + +type curveMeta struct { + id int64 + name string + fieldModulus *big.Int + groupOrder *big.Int + supportsPairing bool +} + +var curveByID = map[int64]*curveMeta{ + CurveSecp256k1: { + id: CurveSecp256k1, + name: "secp256k1", + fieldModulus: gnarksecp256k1fp.Modulus(), + groupOrder: gnarksecp256k1fr.Modulus(), + }, + CurveSecp256r1: { + id: CurveSecp256r1, + name: "secp256r1", + fieldModulus: new(big.Int).Set(elliptic.P256().Params().P), + groupOrder: new(big.Int).Set(elliptic.P256().Params().N), + }, + CurveAltBN128: { + id: CurveAltBN128, + name: "alt_bn128", + fieldModulus: gnarkbn254fp.Modulus(), + groupOrder: gnarkbn254fr.Modulus(), + supportsPairing: true, + }, +} + +// ecPair is one (G1, G2) input to an EIP-197-style pairing-product check +// on alt_bn128. G1 coordinates are field elements; G2 coordinates are Fp2 +// elements represented as (c0, c1) per gnark's E2 layout. +type ecPair struct { + g1X, g1Y *big.Int + g2XC0, g2XC1, g2YC0, g2YC1 *big.Int +} + +var ( + errECPointOffCurve = errors.New("point not on curve") + errECG2NotInSubgroup = errors.New("G2 point not in r-subgroup") +) + +// popCurveID pops the top stack item and resolves it to a known curve. +// Negative IDs, non-int64 IDs, and unknown IDs all fail script execution. +func popCurveID(vm *Engine) (*curveMeta, error) { + n, err := vm.dstack.PopBigNum() + if err != nil { + return nil, err + } + if n.Sign() < 0 { + return nil, scriptError(txscript.ErrInvalidStackOperation, + "negative curve id") + } + bi := n.BigInt() + if !bi.IsInt64() { + return nil, scriptError(txscript.ErrInvalidStackOperation, + "curve id out of range") + } + id := bi.Int64() + meta, ok := curveByID[id] + if !ok { + return nil, scriptError(txscript.ErrInvalidStackOperation, + fmt.Sprintf("unsupported curve id %d", id)) + } + return meta, nil +} + +// popInFieldElement pops a BigNum and enforces 0 ≤ n < modulus. +// The descriptive `what` is included in error messages for failure mode +// disambiguation in tests and debugging. +func popInFieldElement(vm *Engine, modulus *big.Int, what string) (*big.Int, error) { + n, err := vm.dstack.PopBigNum() + if err != nil { + return nil, err + } + if n.Sign() < 0 { + return nil, scriptError(txscript.ErrInvalidStackOperation, + fmt.Sprintf("negative %s", what)) + } + bi := n.BigInt() + if bi.Cmp(modulus) >= 0 { + return nil, scriptError(txscript.ErrInvalidStackOperation, + fmt.Sprintf("%s not less than field modulus", what)) + } + return bi, nil +} + +// popInGroupScalar pops a BigNum and enforces 0 ≤ k < order. +func popInGroupScalar(vm *Engine, order *big.Int) (*big.Int, error) { + n, err := vm.dstack.PopBigNum() + if err != nil { + return nil, err + } + if n.Sign() < 0 { + return nil, scriptError(txscript.ErrInvalidStackOperation, + "negative scalar") + } + bi := n.BigInt() + if bi.Cmp(order) >= 0 { + return nil, scriptError(txscript.ErrInvalidStackOperation, + "scalar not less than group order") + } + return bi, nil +} + +// pushECCoord pushes a non-negative big.Int as a canonical Arkade BigNum. +func pushECCoord(vm *Engine, v *big.Int) error { + return vm.dstack.PushBigNum(BigNum{big: v, useBig: true}) +} + +// pushECPoint pushes (x, y) as two BigNums, x first. +func pushECPoint(vm *Engine, x, y *big.Int) error { + if err := pushECCoord(vm, x); err != nil { + return err + } + return pushECCoord(vm, y) +} + +// opcodeECAdd implements OP_ECADD. +// Stack transformation: [... x1 y1 x2 y2 curve_id] -> [... x3 y3] +func opcodeECAdd(op *opcode, _ []byte, vm *Engine) error { + meta, err := popCurveID(vm) + if err != nil { + return err + } + + y2, err := popInFieldElement(vm, meta.fieldModulus, "y2") + if err != nil { + return err + } + x2, err := popInFieldElement(vm, meta.fieldModulus, "x2") + if err != nil { + return err + } + y1, err := popInFieldElement(vm, meta.fieldModulus, "y1") + if err != nil { + return err + } + x1, err := popInFieldElement(vm, meta.fieldModulus, "x1") + if err != nil { + return err + } + + rx, ry, err := ecAdd(meta.id, x1, y1, x2, y2) + if err != nil { + return scriptError(txscript.ErrInvalidStackOperation, err.Error()) + } + return pushECPoint(vm, rx, ry) +} + +// opcodeECMul implements OP_ECMUL. +// Stack transformation: [... x y k curve_id] -> [... x2 y2] +func opcodeECMul(op *opcode, _ []byte, vm *Engine) error { + meta, err := popCurveID(vm) + if err != nil { + return err + } + + k, err := popInGroupScalar(vm, meta.groupOrder) + if err != nil { + return err + } + y, err := popInFieldElement(vm, meta.fieldModulus, "y") + if err != nil { + return err + } + x, err := popInFieldElement(vm, meta.fieldModulus, "x") + if err != nil { + return err + } + + rx, ry, err := ecMul(meta.id, x, y, k) + if err != nil { + return scriptError(txscript.ErrInvalidStackOperation, err.Error()) + } + return pushECPoint(vm, rx, ry) +} + +// opcodeECPairing implements OP_ECPAIRING. +// Stack transformation: +// +// [... (g1_x g1_y g2_x_c1 g2_x_c0 g2_y_c1 g2_y_c0)... pair_count curve_id] +// -> [... bool] +func opcodeECPairing(op *opcode, _ []byte, vm *Engine) error { + meta, err := popCurveID(vm) + if err != nil { + return err + } + if !meta.supportsPairing { + return scriptError(txscript.ErrInvalidStackOperation, + fmt.Sprintf("curve %s does not support pairing", meta.name)) + } + + countBN, err := vm.dstack.PopBigNum() + if err != nil { + return err + } + if countBN.Sign() < 0 { + return scriptError(txscript.ErrInvalidStackOperation, + "negative pair_count") + } + countBI := countBN.BigInt() + if !countBI.IsInt64() { + return scriptError(txscript.ErrInvalidStackOperation, + "pair_count out of range") + } + count := countBI.Int64() + if count > maxECPairingCount { + return scriptError(txscript.ErrInvalidStackOperation, + fmt.Sprintf("pair_count %d exceeds max %d", count, maxECPairingCount)) + } + + pairs := make([]ecPair, count) + // For each pair the stack layout (bottom -> top) is + // g1_x g1_y g2_x_c1 g2_x_c0 g2_y_c1 g2_y_c0. + // Pop from the top so the last pair on the stack is filled first. + for i := int(count) - 1; i >= 0; i-- { + yC0, err := popInFieldElement(vm, meta.fieldModulus, "g2_y_c0") + if err != nil { + return err + } + yC1, err := popInFieldElement(vm, meta.fieldModulus, "g2_y_c1") + if err != nil { + return err + } + xC0, err := popInFieldElement(vm, meta.fieldModulus, "g2_x_c0") + if err != nil { + return err + } + xC1, err := popInFieldElement(vm, meta.fieldModulus, "g2_x_c1") + if err != nil { + return err + } + g1Y, err := popInFieldElement(vm, meta.fieldModulus, "g1_y") + if err != nil { + return err + } + g1X, err := popInFieldElement(vm, meta.fieldModulus, "g1_x") + if err != nil { + return err + } + pairs[i] = ecPair{ + g1X: g1X, + g1Y: g1Y, + g2XC0: xC0, + g2XC1: xC1, + g2YC0: yC0, + g2YC1: yC1, + } + } + + ok, err := bn254PairingCheck(pairs) + if err != nil { + return scriptError(txscript.ErrInvalidStackOperation, err.Error()) + } + vm.dstack.PushBool(ok) + return nil +} + +// ecAdd dispatches OP_ECADD to the right backend. +func ecAdd(curveID int64, x1, y1, x2, y2 *big.Int) (*big.Int, *big.Int, error) { + switch curveID { + case CurveSecp256k1: + return ecAddSecp256k1(x1, y1, x2, y2) + case CurveSecp256r1: + return ecAddSecp256r1(x1, y1, x2, y2) + case CurveAltBN128: + return ecAddBN254G1(x1, y1, x2, y2) + default: + return nil, nil, fmt.Errorf("unsupported curve id %d", curveID) + } +} + +// ecMul dispatches OP_ECMUL to the right backend. +func ecMul(curveID int64, x, y, k *big.Int) (*big.Int, *big.Int, error) { + switch curveID { + case CurveSecp256k1: + return ecMulSecp256k1(x, y, k) + case CurveSecp256r1: + return ecMulSecp256r1(x, y, k) + case CurveAltBN128: + return ecMulBN254G1(x, y, k) + default: + return nil, nil, fmt.Errorf("unsupported curve id %d", curveID) + } +} + +// secp256k1 — gnark-crypto. + +// ecAddSecp256k1 adds two affine secp256k1 points. (0, 0) denotes the point +// at infinity. Returns (0, 0) when the geometric sum is infinity. +func ecAddSecp256k1(x1, y1, x2, y2 *big.Int) (*big.Int, *big.Int, error) { + var p1, p2, r gnarksecp256k1.G1Affine + setSecp256k1Affine(&p1, x1, y1) + setSecp256k1Affine(&p2, x2, y2) + if !p1.IsOnCurve() { + return nil, nil, errECPointOffCurve + } + if !p2.IsOnCurve() { + return nil, nil, errECPointOffCurve + } + r.Add(&p1, &p2) + rx, ry := secp256k1AffineCoords(&r) + return rx, ry, nil +} + +// ecMulSecp256k1 returns k * P on secp256k1. P=(0,0) denotes infinity. k=0 +// returns infinity. The caller must enforce 0 ≤ k < group order. +func ecMulSecp256k1(x, y, k *big.Int) (*big.Int, *big.Int, error) { + var p, r gnarksecp256k1.G1Affine + setSecp256k1Affine(&p, x, y) + if !p.IsOnCurve() { + return nil, nil, errECPointOffCurve + } + r.ScalarMultiplication(&p, k) + rx, ry := secp256k1AffineCoords(&r) + return rx, ry, nil +} + +func setSecp256k1Affine(p *gnarksecp256k1.G1Affine, x, y *big.Int) { + if x.Sign() == 0 && y.Sign() == 0 { + p.X.SetZero() + p.Y.SetZero() + return + } + p.X.SetBigInt(x) + p.Y.SetBigInt(y) +} + +func secp256k1AffineCoords(p *gnarksecp256k1.G1Affine) (*big.Int, *big.Int) { + if p.IsInfinity() { + return new(big.Int), new(big.Int) + } + var x, y big.Int + p.X.BigInt(&x) + p.Y.BigInt(&y) + return &x, &y +} + +// alt_bn128 G1 — gnark-crypto. + +// ecAddBN254G1 adds two affine alt_bn128 G1 points. Same infinity convention +// as ecAddSecp256k1. +func ecAddBN254G1(x1, y1, x2, y2 *big.Int) (*big.Int, *big.Int, error) { + var p1, p2, r gnarkbn254.G1Affine + setBN254G1Affine(&p1, x1, y1) + setBN254G1Affine(&p2, x2, y2) + if !p1.IsOnCurve() { + return nil, nil, errECPointOffCurve + } + if !p2.IsOnCurve() { + return nil, nil, errECPointOffCurve + } + r.Add(&p1, &p2) + rx, ry := bn254G1AffineCoords(&r) + return rx, ry, nil +} + +// ecMulBN254G1 returns k * P on alt_bn128 G1. +func ecMulBN254G1(x, y, k *big.Int) (*big.Int, *big.Int, error) { + var p, r gnarkbn254.G1Affine + setBN254G1Affine(&p, x, y) + if !p.IsOnCurve() { + return nil, nil, errECPointOffCurve + } + r.ScalarMultiplication(&p, k) + rx, ry := bn254G1AffineCoords(&r) + return rx, ry, nil +} + +func setBN254G1Affine(p *gnarkbn254.G1Affine, x, y *big.Int) { + if x.Sign() == 0 && y.Sign() == 0 { + p.X.SetZero() + p.Y.SetZero() + return + } + p.X.SetBigInt(x) + p.Y.SetBigInt(y) +} + +func bn254G1AffineCoords(p *gnarkbn254.G1Affine) (*big.Int, *big.Int) { + if p.IsInfinity() { + return new(big.Int), new(big.Int) + } + var x, y big.Int + p.X.BigInt(&x) + p.Y.BigInt(&y) + return &x, &y +} + +// alt_bn128 pairing — gnark-crypto. + +// bn254PairingCheck returns whether the product of pairings is the identity +// in GT. Validates G1 on-curve and G2 on-curve + in-subgroup before invoking +// gnark's PairingCheck. An empty input set returns true to match EIP-197. +func bn254PairingCheck(pairs []ecPair) (bool, error) { + if len(pairs) == 0 { + return true, nil + } + g1s := make([]gnarkbn254.G1Affine, len(pairs)) + g2s := make([]gnarkbn254.G2Affine, len(pairs)) + for i, p := range pairs { + setBN254G1Affine(&g1s[i], p.g1X, p.g1Y) + setBN254G2Affine(&g2s[i], p.g2XC0, p.g2XC1, p.g2YC0, p.g2YC1) + if !g1s[i].IsOnCurve() { + return false, errECPointOffCurve + } + if !g2s[i].IsOnCurve() { + return false, errECPointOffCurve + } + // BN254 G1 has cofactor 1, so on-curve implies in-subgroup. G2 + // requires an explicit subgroup test for security per EIP-197. + if !g2s[i].IsInSubGroup() { + return false, errECG2NotInSubgroup + } + } + return gnarkbn254.PairingCheck(g1s, g2s) +} + +func setBN254G2Affine(p *gnarkbn254.G2Affine, xC0, xC1, yC0, yC1 *big.Int) { + if xC0.Sign() == 0 && xC1.Sign() == 0 && yC0.Sign() == 0 && yC1.Sign() == 0 { + p.X.A0.SetZero() + p.X.A1.SetZero() + p.Y.A0.SetZero() + p.Y.A1.SetZero() + return + } + p.X.A0.SetBigInt(xC0) + p.X.A1.SetBigInt(xC1) + p.Y.A0.SetBigInt(yC0) + p.Y.A1.SetBigInt(yC1) +} + +// secp256r1 / NIST P-256 — Go standard library. + +// crypto/elliptic's Curve.IsOnCurve, Curve.Add, and Curve.ScalarMult are +// marked deprecated because the higher-level packages crypto/ecdh and +// crypto/ecdsa cover most consumers. We need the raw point arithmetic for +// the EC opcodes, so the deprecation is intentionally suppressed below. + +// ecAddSecp256r1 adds two affine P-256 points. Validates on-curve manually +// because crypto/elliptic.P256().IsOnCurve rejects (0, 0) — the spec uses +// that pair as the wire representation of the point at infinity. +func ecAddSecp256r1(x1, y1, x2, y2 *big.Int) (*big.Int, *big.Int, error) { + inf1 := x1.Sign() == 0 && y1.Sign() == 0 + inf2 := x2.Sign() == 0 && y2.Sign() == 0 + curve := elliptic.P256() + if !inf1 && !curve.IsOnCurve(x1, y1) { //nolint:staticcheck + return nil, nil, errECPointOffCurve + } + if !inf2 && !curve.IsOnCurve(x2, y2) { //nolint:staticcheck + return nil, nil, errECPointOffCurve + } + switch { + case inf1 && inf2: + return new(big.Int), new(big.Int), nil + case inf1: + return new(big.Int).Set(x2), new(big.Int).Set(y2), nil + case inf2: + return new(big.Int).Set(x1), new(big.Int).Set(y1), nil + } + rx, ry := curve.Add(x1, y1, x2, y2) //nolint:staticcheck + return rx, ry, nil +} + +// ecMulSecp256r1 returns k * P on P-256. P=(0,0) and k=0 both produce +// infinity. The caller must enforce 0 ≤ k < group order. +func ecMulSecp256r1(x, y, k *big.Int) (*big.Int, *big.Int, error) { + curve := elliptic.P256() + inf := x.Sign() == 0 && y.Sign() == 0 + if !inf && !curve.IsOnCurve(x, y) { //nolint:staticcheck + return nil, nil, errECPointOffCurve + } + if inf || k.Sign() == 0 { + return new(big.Int), new(big.Int), nil + } + rx, ry := curve.ScalarMult(x, y, k.Bytes()) //nolint:staticcheck + return rx, ry, nil +} diff --git a/pkg/arkade/ec_ops_test.go b/pkg/arkade/ec_ops_test.go new file mode 100644 index 0000000..2b10bf6 --- /dev/null +++ b/pkg/arkade/ec_ops_test.go @@ -0,0 +1,989 @@ +package arkade + +import ( + "crypto/elliptic" + "math/big" + "testing" + + "github.com/btcsuite/btcd/txscript" + gnarkbn254 "github.com/consensys/gnark-crypto/ecc/bn254" + gnarkbn254fp "github.com/consensys/gnark-crypto/ecc/bn254/fp" + gnarkbn254fr "github.com/consensys/gnark-crypto/ecc/bn254/fr" + gnarksecp256k1 "github.com/consensys/gnark-crypto/ecc/secp256k1" + gnarksecp256k1fp "github.com/consensys/gnark-crypto/ecc/secp256k1/fp" + gnarksecp256k1fr "github.com/consensys/gnark-crypto/ecc/secp256k1/fr" + "github.com/stretchr/testify/require" +) + +// bnBytes returns the canonical Arkade BigNum encoding of v. It panics on +// values that cannot be encoded (oversized); callers in tests pass sane +// in-range values. +func bnBytes(v *big.Int) []byte { + b, err := (BigNum{big: v, useBig: true}).Bytes() + if err != nil { + panic(err) + } + return b +} + +func bnBytesUint(v uint64) []byte { + return bnBytes(new(big.Int).SetUint64(v)) +} + +func bigInt(s string, base int) *big.Int { + v, ok := new(big.Int).SetString(s, base) + if !ok { + panic("bad bigint literal: " + s) + } + return v +} + +// Curve generators and helpers. + +func secp256k1Gen() (x, y *big.Int) { + var g gnarksecp256k1.G1Affine + _, gAff := gnarksecp256k1.Generators() + g.Set(&gAff) + var bx, by big.Int + g.X.BigInt(&bx) + g.Y.BigInt(&by) + return &bx, &by +} + +func secp256k1Double(x, y *big.Int) (*big.Int, *big.Int) { + var p, r gnarksecp256k1.G1Affine + p.X.SetBigInt(x) + p.Y.SetBigInt(y) + r.Double(&p) + var bx, by big.Int + r.X.BigInt(&bx) + r.Y.BigInt(&by) + return &bx, &by +} + +func secp256k1NegY(y *big.Int) *big.Int { + mod := gnarksecp256k1fp.Modulus() + return new(big.Int).Mod(new(big.Int).Neg(y), mod) +} + +func secp256r1Gen() (x, y *big.Int) { + p := elliptic.P256().Params() + return new(big.Int).Set(p.Gx), new(big.Int).Set(p.Gy) +} + +func secp256r1Double(x, y *big.Int) (*big.Int, *big.Int) { + return elliptic.P256().Double(x, y) +} + +func secp256r1NegY(y *big.Int) *big.Int { + p := elliptic.P256().Params().P + return new(big.Int).Mod(new(big.Int).Neg(y), p) +} + +func bn254G1Gen() (x, y *big.Int) { + _, _, g1Aff, _ := gnarkbn254.Generators() + var bx, by big.Int + g1Aff.X.BigInt(&bx) + g1Aff.Y.BigInt(&by) + return &bx, &by +} + +func bn254G1Double(x, y *big.Int) (*big.Int, *big.Int) { + var p, r gnarkbn254.G1Affine + p.X.SetBigInt(x) + p.Y.SetBigInt(y) + r.Double(&p) + var bx, by big.Int + r.X.BigInt(&bx) + r.Y.BigInt(&by) + return &bx, &by +} + +func bn254G1NegY(y *big.Int) *big.Int { + mod := gnarkbn254fp.Modulus() + return new(big.Int).Mod(new(big.Int).Neg(y), mod) +} + +func bn254G2Gen() (xC0, xC1, yC0, yC1 *big.Int) { + _, _, _, g2Aff := gnarkbn254.Generators() + var xa0, xa1, ya0, ya1 big.Int + g2Aff.X.A0.BigInt(&xa0) + g2Aff.X.A1.BigInt(&xa1) + g2Aff.Y.A0.BigInt(&ya0) + g2Aff.Y.A1.BigInt(&ya1) + return &xa0, &xa1, &ya0, &ya1 +} + +// nonSubgroupG2 produces a deterministic G2 point that is on the alt_bn128 +// twist curve but NOT in the r-subgroup. We rely on MapToCurve2: it returns +// a curve point but does NOT clear the cofactor, so the result is overwhelmingly +// likely to be off-subgroup. The function panics if the chosen seed lands in +// the subgroup so a test author can pick a different seed. +func nonSubgroupG2(t *testing.T) (xC0, xC1, yC0, yC1 *big.Int) { + t.Helper() + var seed gnarkbn254.G2Affine + seed.X.A0.SetUint64(1) + seed.X.A1.SetUint64(0) + p := gnarkbn254.MapToCurve2(&seed.X) + require.True(t, p.IsOnCurve(), "MapToCurve2 should land on curve") + require.False(t, p.IsInSubGroup(), "test seed unexpectedly landed in r-subgroup; pick a new seed") + var xa0, xa1, ya0, ya1 big.Int + p.X.A0.BigInt(&xa0) + p.X.A1.BigInt(&xa1) + p.Y.A0.BigInt(&ya0) + p.Y.A1.BigInt(&ya1) + return &xa0, &xa1, &ya0, &ya1 +} + +// requireCanonicalBigNums checks that every byte slice on top of `after`'s +// stack is a minimally encoded BigNum. We round-trip through +// BigNumFromBytes — which itself rejects non-minimal encodings — and assert +// that the canonical re-encoding equals the original bytes. +func requireCanonicalBigNums(t *testing.T, items [][]byte) { + t.Helper() + for i, b := range items { + n, err := BigNumFromBytes(b) + require.NoErrorf(t, err, "stack item %d is not a valid BigNum: %v", i, err) + out, err := n.Bytes() + require.NoErrorf(t, err, "stack item %d failed to re-encode: %v", i, err) + require.Equalf(t, b, out, "stack item %d is not canonically encoded", i) + } +} + +// ecPropertyChecker returns a checker that: +// - asserts alt-stack and cond-stack are untouched; +// - if an error occurred, asserts the error code is one of the documented +// consensus codes; +// - if no error, asserts the stack delta is exactly outputs - inputs and +// that all newly pushed items are canonical BigNums. +func ecPropertyChecker(inputs, outputs int) opcodePropertyChecker { + return func(t *testing.T, c opcodeCheckContext) { + t.Helper() + require.Equal(t, c.before.GetAltStack(), c.after.GetAltStack()) + require.Equal(t, c.before.condStack, c.after.condStack) + if c.execErr != nil { + requireScriptErrorCodeIn(t, c.execErr, + txscript.ErrInvalidStackOperation, + txscript.ErrMinimalData, + txscript.ErrNumberTooBig, + ) + return + } + beforeStack := c.before.GetStack() + afterStack := c.after.GetStack() + require.GreaterOrEqual(t, len(beforeStack), inputs, "not enough inputs on stack before opcode") + require.Equal(t, len(beforeStack)-inputs+outputs, len(afterStack)) + // New pushes are the last `outputs` items. + requireCanonicalBigNums(t, afterStack[len(afterStack)-outputs:]) + } +} + +// ecAddSpec builds the OP_ECADD opcodeSpec with the spec's test matrix. +func ecAddSpec() *opcodeSpec { + // Common values reused across vectors. + g1x, g1y := secp256k1Gen() + g1x2, g1y2 := secp256k1Double(g1x, g1y) + g1negY := secp256k1NegY(g1y) + + p1x, p1y := secp256r1Gen() + p1x2, p1y2 := secp256r1Double(p1x, p1y) + p1negY := secp256r1NegY(p1y) + + bnGx, bnGy := bn254G1Gen() + bnG2x, bnG2y := bn254G1Double(bnGx, bnGy) + bnGnegY := bn254G1NegY(bnGy) + + var zero []byte + + pSecp256k1 := gnarksecp256k1fp.Modulus() + pP256 := elliptic.P256().Params().P + pBN254 := gnarkbn254fp.Modulus() + + return &opcodeSpec{ + opcode: OP_ECADD, + checkProperties: ecPropertyChecker(5, 2), + validVectors: []opcodeVector{ + { + name: "secp256k1_G_plus_G", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(g1x), bnBytes(g1y), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{bnBytes(g1x2), bnBytes(g1y2)}, + }, + { + name: "secp256k1_G_plus_negG_is_infinity", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(g1x), bnBytes(g1negY), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "secp256k1_infinity_plus_G_is_G", + inputStack: [][]byte{ + zero, zero, + bnBytes(g1x), bnBytes(g1y), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{bnBytes(g1x), bnBytes(g1y)}, + }, + { + name: "secp256k1_infinity_plus_infinity", + inputStack: [][]byte{ + zero, zero, + zero, zero, + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "secp256r1_G_plus_G", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + bnBytes(p1x), bnBytes(p1y), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{bnBytes(p1x2), bnBytes(p1y2)}, + }, + { + name: "secp256r1_G_plus_negG_is_infinity", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + bnBytes(p1x), bnBytes(p1negY), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "secp256r1_infinity_plus_infinity", + inputStack: [][]byte{ + zero, zero, + zero, zero, + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "secp256r1_infinity_plus_G_is_G", + inputStack: [][]byte{ + zero, zero, + bnBytes(p1x), bnBytes(p1y), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{bnBytes(p1x), bnBytes(p1y)}, + }, + { + name: "alt_bn128_G_plus_G", + inputStack: [][]byte{ + bnBytes(bnGx), bnBytes(bnGy), + bnBytes(bnGx), bnBytes(bnGy), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedStack: [][]byte{bnBytes(bnG2x), bnBytes(bnG2y)}, + }, + { + name: "alt_bn128_G_plus_negG_is_infinity", + inputStack: [][]byte{ + bnBytes(bnGx), bnBytes(bnGy), + bnBytes(bnGx), bnBytes(bnGnegY), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedStack: [][]byte{zero, zero}, + }, + }, + invalidVectors: []opcodeVector{ + { + name: "underflow", + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "missing_curve_id", + inputStack: [][]byte{zero, zero, zero, zero}, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "unsupported_curve_id", + inputStack: [][]byte{ + zero, zero, zero, zero, + bnBytesUint(99), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "negative_curve_id", + inputStack: [][]byte{ + zero, zero, zero, zero, + {0x81}, // -1 + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "negative_coordinate", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + {0x81}, // -1 as x2 + bnBytes(g1y), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "out_of_field_coordinate", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(pSecp256k1), bnBytes(g1y), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "off_curve_secp256k1", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytesUint(1), bnBytesUint(1), // (1, 1) not on curve + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "off_curve_secp256r1", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + bnBytesUint(1), bnBytesUint(1), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "off_curve_alt_bn128", + inputStack: [][]byte{ + bnBytes(bnGx), bnBytes(bnGy), + bnBytesUint(1), bnBytesUint(1), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "non_minimal_coordinate", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + {0x05, 0x00}, // non-minimal encoding of 5 + bnBytes(g1y), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrMinimalData, + }, + { + name: "p256_out_of_field", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + bnBytes(pP256), bnBytes(p1y), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "bn254_out_of_field", + inputStack: [][]byte{ + bnBytes(bnGx), bnBytes(bnGy), + bnBytes(pBN254), bnBytes(bnGy), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + }, + } +} + +// ecMulSpec builds the OP_ECMUL opcodeSpec. +func ecMulSpec() *opcodeSpec { + g1x, g1y := secp256k1Gen() + g1x2, g1y2 := secp256k1Double(g1x, g1y) + + p1x, p1y := secp256r1Gen() + p1x2, p1y2 := secp256r1Double(p1x, p1y) + + bnGx, bnGy := bn254G1Gen() + bnG2x, bnG2y := bn254G1Double(bnGx, bnGy) + + nSecp256k1 := gnarksecp256k1fr.Modulus() + nP256 := elliptic.P256().Params().N + nBN254 := gnarkbn254fr.Modulus() + pSecp256k1 := gnarksecp256k1fp.Modulus() + + // (order - 1) * G = -G. Last valid scalar — confirms the boundary + // check on the scalar is `<` and not `<=`. + one := big.NewInt(1) + nSecp256k1Minus1 := new(big.Int).Sub(nSecp256k1, one) + nP256Minus1 := new(big.Int).Sub(nP256, one) + nBN254Minus1 := new(big.Int).Sub(nBN254, one) + g1negY := secp256k1NegY(g1y) + p1negY := secp256r1NegY(p1y) + bnGnegY := bn254G1NegY(bnGy) + + var zero []byte + + return &opcodeSpec{ + opcode: OP_ECMUL, + checkProperties: ecPropertyChecker(4, 2), + validVectors: []opcodeVector{ + { + name: "secp256k1_k_zero_is_infinity", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + zero, + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "secp256k1_k_one", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytesUint(1), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{bnBytes(g1x), bnBytes(g1y)}, + }, + { + name: "secp256k1_k_two", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytesUint(2), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{bnBytes(g1x2), bnBytes(g1y2)}, + }, + { + name: "secp256k1_infinity_times_anything", + inputStack: [][]byte{ + zero, zero, + bnBytesUint(42), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "secp256k1_k_eq_order_minus_one_is_negG", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(nSecp256k1Minus1), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedStack: [][]byte{bnBytes(g1x), bnBytes(g1negY)}, + }, + { + name: "secp256r1_k_one", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + bnBytesUint(1), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{bnBytes(p1x), bnBytes(p1y)}, + }, + { + name: "secp256r1_k_two", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + bnBytesUint(2), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{bnBytes(p1x2), bnBytes(p1y2)}, + }, + { + name: "secp256r1_k_zero", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + zero, + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "secp256r1_infinity_times_anything", + inputStack: [][]byte{ + zero, zero, + bnBytesUint(42), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "secp256r1_k_eq_order_minus_one_is_negG", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + bnBytes(nP256Minus1), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedStack: [][]byte{bnBytes(p1x), bnBytes(p1negY)}, + }, + { + name: "alt_bn128_k_two", + inputStack: [][]byte{ + bnBytes(bnGx), bnBytes(bnGy), + bnBytesUint(2), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedStack: [][]byte{bnBytes(bnG2x), bnBytes(bnG2y)}, + }, + { + name: "alt_bn128_k_zero", + inputStack: [][]byte{ + bnBytes(bnGx), bnBytes(bnGy), + zero, + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedStack: [][]byte{zero, zero}, + }, + { + name: "alt_bn128_k_eq_order_minus_one_is_negG", + inputStack: [][]byte{ + bnBytes(bnGx), bnBytes(bnGy), + bnBytes(nBN254Minus1), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedStack: [][]byte{bnBytes(bnGx), bnBytes(bnGnegY)}, + }, + }, + invalidVectors: []opcodeVector{ + { + name: "underflow", + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "unsupported_curve_id", + inputStack: [][]byte{ + zero, zero, zero, bnBytesUint(99), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "scalar_equal_to_group_order_secp256k1", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(nSecp256k1), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "scalar_equal_to_group_order_secp256r1", + inputStack: [][]byte{ + bnBytes(p1x), bnBytes(p1y), + bnBytes(nP256), + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "scalar_equal_to_group_order_alt_bn128", + inputStack: [][]byte{ + bnBytes(bnGx), bnBytes(bnGy), + bnBytes(nBN254), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "negative_scalar", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + {0x81}, // -1 + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "off_curve_secp256k1", + inputStack: [][]byte{ + bnBytesUint(1), bnBytesUint(1), + bnBytesUint(1), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "out_of_field_coordinate", + inputStack: [][]byte{ + bnBytes(pSecp256k1), bnBytes(g1y), + bnBytesUint(1), + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "non_minimal_scalar", + inputStack: [][]byte{ + bnBytes(g1x), bnBytes(g1y), + {0x05, 0x00}, + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrMinimalData, + }, + }, + } +} + +// pairingTrueVectors returns a stack that encodes a valid pairing-product +// check evaluating to true: e(G1, G2) * e(-G1, G2) == 1. +func pairingTrueVectors(t *testing.T) [][]byte { + t.Helper() + g1x, g1y := bn254G1Gen() + negY := bn254G1NegY(g1y) + g2xC0, g2xC1, g2yC0, g2yC1 := bn254G2Gen() + // Stack layout for each pair (bottom -> top): + // g1_x g1_y g2_x_c1 g2_x_c0 g2_y_c1 g2_y_c0 + pair := func(x, y *big.Int) [][]byte { + return [][]byte{ + bnBytes(x), bnBytes(y), + bnBytes(g2xC1), bnBytes(g2xC0), + bnBytes(g2yC1), bnBytes(g2yC0), + } + } + var out [][]byte + out = append(out, pair(g1x, g1y)...) + out = append(out, pair(g1x, negY)...) + out = append(out, bnBytesUint(2), bnBytesUint(uint64(CurveAltBN128))) + return out +} + +func pairingFalseVectors() [][]byte { + g1x, g1y := bn254G1Gen() + g2xC0, g2xC1, g2yC0, g2yC1 := bn254G2Gen() + stack := [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(g2xC1), bnBytes(g2xC0), + bnBytes(g2yC1), bnBytes(g2yC0), + bnBytesUint(1), bnBytesUint(uint64(CurveAltBN128)), + } + return stack +} + +func TestECPairingTrueValid(t *testing.T) { + // Sanity check that the chosen vectors do form a valid e(G,G2)*e(-G,G2)=1. + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(pairingTrueVectors(t)) + require.NoError(t, invokeOpcodeWithData(OP_ECPAIRING, nil, vm)) + require.Equal(t, [][]byte{{0x01}}, vm.GetStack()) +} + +func TestECPairingFalseValid(t *testing.T) { + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(pairingFalseVectors()) + require.NoError(t, invokeOpcodeWithData(OP_ECPAIRING, nil, vm)) + require.Equal(t, [][]byte{nil}, vm.GetStack()) +} + +// pairingPropertyChecker is a relaxed version that does not require the +// pushed boolean to round-trip through BigNumFromBytes — true is `{0x01}`, +// false is `nil`, both consensus-canonical bool encodings. +func pairingPropertyChecker() opcodePropertyChecker { + return func(t *testing.T, c opcodeCheckContext) { + t.Helper() + require.Equal(t, c.before.GetAltStack(), c.after.GetAltStack()) + require.Equal(t, c.before.condStack, c.after.condStack) + if c.execErr != nil { + requireScriptErrorCodeIn(t, c.execErr, + txscript.ErrInvalidStackOperation, + txscript.ErrMinimalData, + txscript.ErrNumberTooBig, + ) + return + } + // On success, the after-stack must have grown by exactly one item + // (a bool), and that item must be either `{0x01}` (true) or `{}` (false). + afterStack := c.after.GetStack() + require.NotEmpty(t, afterStack) + top := afterStack[len(afterStack)-1] + require.True(t, len(top) == 0 || (len(top) == 1 && top[0] == 0x01), + "OP_ECPAIRING pushed a non-canonical bool %x", top) + } +} + +// ecPairingSpec builds the OP_ECPAIRING opcodeSpec. +func ecPairingSpec() *opcodeSpec { + var zero []byte + + return &opcodeSpec{ + opcode: OP_ECPAIRING, + checkProperties: pairingPropertyChecker(), + validVectors: []opcodeVector{ + { + name: "empty_set_returns_true", + inputStack: [][]byte{ + zero, // pair_count = 0 + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedStack: [][]byte{{0x01}}, + }, + }, + invalidVectors: []opcodeVector{ + { + name: "underflow", + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "unsupported_curve_id", + inputStack: [][]byte{ + zero, + bnBytesUint(99), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "unsupported_pairing_curve_secp256k1", + inputStack: [][]byte{ + zero, + bnBytesUint(uint64(CurveSecp256k1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "unsupported_pairing_curve_secp256r1", + inputStack: [][]byte{ + zero, + bnBytesUint(uint64(CurveSecp256r1)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "negative_pair_count", + inputStack: [][]byte{ + {0x81}, // -1 + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "pair_count_exceeds_max", + inputStack: [][]byte{ + bnBytesUint(uint64(maxECPairingCount + 1)), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "pair_count_underflow", + inputStack: [][]byte{ + // Claim 1 pair but provide no coords. + bnBytesUint(1), + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedError: txscript.ErrInvalidStackOperation, + }, + { + name: "non_minimal_pair_count", + inputStack: [][]byte{ + {0x01, 0x00}, + bnBytesUint(uint64(CurveAltBN128)), + }, + expectedError: txscript.ErrMinimalData, + }, + }, + } +} + +// TestECPairingOffCurveG1 verifies that an off-curve G1 input fails execution. +func TestECPairingOffCurveG1(t *testing.T) { + g2xC0, g2xC1, g2yC0, g2yC1 := bn254G2Gen() + stack := [][]byte{ + // G1 = (1, 1), not on curve + bnBytesUint(1), bnBytesUint(1), + bnBytes(g2xC1), bnBytes(g2xC0), + bnBytes(g2yC1), bnBytes(g2yC0), + bnBytesUint(1), bnBytesUint(uint64(CurveAltBN128)), + } + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(stack) + err = invokeOpcodeWithData(OP_ECPAIRING, nil, vm) + requireScriptErrorCode(t, err, txscript.ErrInvalidStackOperation) +} + +// TestECPairingOffCurveG2 verifies that an on-field but off-curve G2 input +// fails execution. We construct an Fp2 element that we know is on the twist +// (via MapToCurve2), then perturb its X coordinate so it leaves the curve. +func TestECPairingOffCurveG2(t *testing.T) { + g1x, g1y := bn254G1Gen() + g2xC0, g2xC1, _, g2yC1 := bn254G2Gen() + // Use the real G2 X but a y that does not satisfy the curve equation. + // Setting yC0 = 0 in general breaks y² = x³ + b' for the generator. + stack := [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(g2xC1), bnBytes(g2xC0), + bnBytes(g2yC1), []byte{}, // yC0 := 0 + bnBytesUint(1), bnBytesUint(uint64(CurveAltBN128)), + } + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(stack) + err = invokeOpcodeWithData(OP_ECPAIRING, nil, vm) + requireScriptErrorCode(t, err, txscript.ErrInvalidStackOperation) +} + +// TestECPairingG2NotInSubgroup verifies that a G2 point on the twist but +// outside the r-subgroup fails execution. +func TestECPairingG2NotInSubgroup(t *testing.T) { + g1x, g1y := bn254G1Gen() + xc0, xc1, yc0, yc1 := nonSubgroupG2(t) + stack := [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(xc1), bnBytes(xc0), + bnBytes(yc1), bnBytes(yc0), + bnBytesUint(1), bnBytesUint(uint64(CurveAltBN128)), + } + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(stack) + err = invokeOpcodeWithData(OP_ECPAIRING, nil, vm) + requireScriptErrorCode(t, err, txscript.ErrInvalidStackOperation) +} + +// TestECPairingOutOfFieldCoordinate covers the field-modulus boundary for +// pairing coordinates on G1. +func TestECPairingOutOfFieldCoordinate(t *testing.T) { + g1y := func() *big.Int { _, y := bn254G1Gen(); return y }() + g2xC0, g2xC1, g2yC0, g2yC1 := bn254G2Gen() + mod := gnarkbn254fp.Modulus() + stack := [][]byte{ + bnBytes(mod), bnBytes(g1y), + bnBytes(g2xC1), bnBytes(g2xC0), + bnBytes(g2yC1), bnBytes(g2yC0), + bnBytesUint(1), bnBytesUint(uint64(CurveAltBN128)), + } + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(stack) + err = invokeOpcodeWithData(OP_ECPAIRING, nil, vm) + requireScriptErrorCode(t, err, txscript.ErrInvalidStackOperation) +} + +// TestECPairingOutOfFieldG2Coordinate covers the field-modulus boundary for +// each of the four G2 Fp2 components. +func TestECPairingOutOfFieldG2Coordinate(t *testing.T) { + g1x, g1y := bn254G1Gen() + g2xC0, g2xC1, g2yC0, g2yC1 := bn254G2Gen() + mod := gnarkbn254fp.Modulus() + cases := []struct { + name string + xC0, xC1, yC0, yC1 *big.Int + }{ + {"g2_x_c0_eq_mod", mod, g2xC1, g2yC0, g2yC1}, + {"g2_x_c1_eq_mod", g2xC0, mod, g2yC0, g2yC1}, + {"g2_y_c0_eq_mod", g2xC0, g2xC1, mod, g2yC1}, + {"g2_y_c1_eq_mod", g2xC0, g2xC1, g2yC0, mod}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stack := [][]byte{ + bnBytes(g1x), bnBytes(g1y), + bnBytes(tc.xC1), bnBytes(tc.xC0), + bnBytes(tc.yC1), bnBytes(tc.yC0), + bnBytesUint(1), bnBytesUint(uint64(CurveAltBN128)), + } + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(stack) + err = invokeOpcodeWithData(OP_ECPAIRING, nil, vm) + requireScriptErrorCode(t, err, txscript.ErrInvalidStackOperation) + }) + } +} + +// TestECPairingNegativeG2Coordinate confirms a negative BigNum in any G2 +// component fails execution. +func TestECPairingNegativeG2Coordinate(t *testing.T) { + g1x, g1y := bn254G1Gen() + g2xC0, g2xC1, g2yC0, g2yC1 := bn254G2Gen() + negOne := []byte{0x81} // canonical minimal encoding of -1 + cases := []struct { + name string + xC0, xC1, yC0, yC1 []byte + }{ + {"g2_x_c0_negative", negOne, bnBytes(g2xC1), bnBytes(g2yC0), bnBytes(g2yC1)}, + {"g2_x_c1_negative", bnBytes(g2xC0), negOne, bnBytes(g2yC0), bnBytes(g2yC1)}, + {"g2_y_c0_negative", bnBytes(g2xC0), bnBytes(g2xC1), negOne, bnBytes(g2yC1)}, + {"g2_y_c1_negative", bnBytes(g2xC0), bnBytes(g2xC1), bnBytes(g2yC0), negOne}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stack := [][]byte{ + bnBytes(g1x), bnBytes(g1y), + tc.xC1, tc.xC0, + tc.yC1, tc.yC0, + bnBytesUint(1), bnBytesUint(uint64(CurveAltBN128)), + } + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(stack) + err = invokeOpcodeWithData(OP_ECPAIRING, nil, vm) + requireScriptErrorCode(t, err, txscript.ErrInvalidStackOperation) + }) + } +} + +// TestECPairingPairCountAtMax verifies that pair_count == maxECPairingCount +// is accepted. Confirms the bound is `>` and not `>=`. The bundle of 16 +// pairs is 8 copies of {(G, G2), (-G, G2)} so the product is the identity +// in GT and the opcode returns true. +func TestECPairingPairCountAtMax(t *testing.T) { + g1x, g1y := bn254G1Gen() + negY := bn254G1NegY(g1y) + g2xC0, g2xC1, g2yC0, g2yC1 := bn254G2Gen() + pair := func(x, y *big.Int) [][]byte { + return [][]byte{ + bnBytes(x), bnBytes(y), + bnBytes(g2xC1), bnBytes(g2xC0), + bnBytes(g2yC1), bnBytes(g2yC0), + } + } + var stack [][]byte + for i := 0; i < maxECPairingCount/2; i++ { + stack = append(stack, pair(g1x, g1y)...) + stack = append(stack, pair(g1x, negY)...) + } + stack = append(stack, + bnBytesUint(uint64(maxECPairingCount)), + bnBytesUint(uint64(CurveAltBN128)), + ) + + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(stack) + require.NoError(t, invokeOpcodeWithData(OP_ECPAIRING, nil, vm)) + require.Equal(t, [][]byte{{0x01}}, vm.GetStack()) +} + +// TestECPairingG2InfinityIsIdentity verifies that pairs containing the G2 +// point at infinity contribute the identity to the product, so a single +// pair (P, 0_G2) produces a true result. +func TestECPairingG2InfinityIsIdentity(t *testing.T) { + g1x, g1y := bn254G1Gen() + z := []byte(nil) + stack := [][]byte{ + bnBytes(g1x), bnBytes(g1y), + z, z, // G2 x: c1=0, c0=0 + z, z, // G2 y: c1=0, c0=0 + bnBytesUint(1), bnBytesUint(uint64(CurveAltBN128)), + } + world := buildOpcodeWorld() + vm, err := newOpcodeEngine(world, 0) + require.NoError(t, err) + vm.SetStack(stack) + require.NoError(t, invokeOpcodeWithData(OP_ECPAIRING, nil, vm)) + require.Equal(t, [][]byte{{0x01}}, vm.GetStack()) +} diff --git a/pkg/arkade/go.mod b/pkg/arkade/go.mod index a13285b..23f28f6 100644 --- a/pkg/arkade/go.mod +++ b/pkg/arkade/go.mod @@ -9,6 +9,7 @@ require ( github.com/btcsuite/btcd/btcec/v2 v2.3.5 github.com/btcsuite/btcd/btcutil/psbt v1.1.9 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 + github.com/consensys/gnark-crypto v0.19.2 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.48.0 @@ -16,10 +17,12 @@ require ( require ( github.com/arkade-os/arkd/pkg/errors v0.0.0-20260303153651-8615412e4dea // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect github.com/btcsuite/btcd/btcutil v1.1.5 // indirect github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/pkg/arkade/go.sum b/pkg/arkade/go.sum index b2f8634..6d21cc5 100644 --- a/pkg/arkade/go.sum +++ b/pkg/arkade/go.sum @@ -5,6 +5,8 @@ github.com/arkade-os/arkd/pkg/ark-lib v0.8.1-0.20260318170839-137daaec3a70 h1:qL github.com/arkade-os/arkd/pkg/ark-lib v0.8.1-0.20260318170839-137daaec3a70/go.mod h1:VpyqrRS8Qk3uAhUTiH417gyC52caAfan/o8aVPDO528= github.com/arkade-os/arkd/pkg/errors v0.0.0-20260303153651-8615412e4dea h1:x9ZwZL+F2b9E0uBZYBVjCLGtlqIE4zahDOY4C89h3X4= github.com/arkade-os/arkd/pkg/errors v0.0.0-20260303153651-8615412e4dea/go.mod h1:NYGE+baj57ynbXNwjISJddMDpMqAWOX27dV22xqFm2A= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 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.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -34,6 +36,9 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= +github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -68,6 +73,12 @@ github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -80,6 +91,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -129,8 +142,9 @@ 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.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/arkade/opcode.go b/pkg/arkade/opcode.go index d4bcecf..d705c63 100644 --- a/pkg/arkade/opcode.go +++ b/pkg/arkade/opcode.go @@ -283,9 +283,9 @@ const ( OP_UNKNOWN221 = 0xdd // 221 OP_UNKNOWN222 = 0xde // 222 OP_UNKNOWN223 = 0xdf // 223 - OP_UNKNOWN224 = 0xe0 // 224 - OP_UNKNOWN225 = 0xe1 // 225 - OP_UNKNOWN226 = 0xe2 // 226 + OP_ECADD = 0xe0 // 224 + OP_ECMUL = 0xe1 // 225 + OP_ECPAIRING = 0xe2 // 226 OP_ECMULSCALARVERIFY = 0xe3 // 227 OP_TWEAKVERIFY = 0xe4 // 228 OP_INSPECTNUMASSETGROUPS = 0xe5 // 229 @@ -584,9 +584,9 @@ var opcodeArray = [256]opcode{ OP_UNKNOWN221: {OP_UNKNOWN221, "OP_UNKNOWN221", 1, opcodeInvalid}, OP_UNKNOWN222: {OP_UNKNOWN222, "OP_UNKNOWN222", 1, opcodeInvalid}, OP_UNKNOWN223: {OP_UNKNOWN223, "OP_UNKNOWN223", 1, opcodeInvalid}, - OP_UNKNOWN224: {OP_UNKNOWN224, "OP_UNKNOWN224", 1, opcodeInvalid}, - OP_UNKNOWN225: {OP_UNKNOWN225, "OP_UNKNOWN225", 1, opcodeInvalid}, - OP_UNKNOWN226: {OP_UNKNOWN226, "OP_UNKNOWN226", 1, opcodeInvalid}, + OP_ECADD: {OP_ECADD, "OP_ECADD", 1, opcodeECAdd}, + OP_ECMUL: {OP_ECMUL, "OP_ECMUL", 1, opcodeECMul}, + OP_ECPAIRING: {OP_ECPAIRING, "OP_ECPAIRING", 1, opcodeECPairing}, OP_ECMULSCALARVERIFY: {OP_ECMULSCALARVERIFY, "OP_ECMULSCALARVERIFY", 1, opcodeECMulScalarVerify}, OP_TWEAKVERIFY: {OP_TWEAKVERIFY, "OP_TWEAKVERIFY", 1, opcodeTweakVerify}, OP_INSPECTNUMASSETGROUPS: {OP_INSPECTNUMASSETGROUPS, "OP_INSPECTNUMASSETGROUPS", 1, opcodeInspectNumAssetGroups}, diff --git a/pkg/arkade/opcode_fuzz_test.go b/pkg/arkade/opcode_fuzz_test.go index 4d2f75c..0a6b972 100644 --- a/pkg/arkade/opcode_fuzz_test.go +++ b/pkg/arkade/opcode_fuzz_test.go @@ -363,6 +363,74 @@ var fuzzCaseBuilders = [256]fuzzCaseBuilder{ OP_INSPECTINASSETCOUNT: assetIndexCaseBuilder{}, OP_INSPECTINASSETAT: assetAtCaseBuilder{}, OP_INSPECTINASSETLOOKUP: assetLookupCaseBuilder{}, + OP_ECADD: ecCurveCaseBuilder{coordPushes: 4}, + OP_ECMUL: ecCurveCaseBuilder{coordPushes: 3}, + OP_ECPAIRING: ecPairingFuzzCaseBuilder{}, +} + +// ecCurveCaseBuilder seeds the stack with a few BigNum-shaped pushes plus a +// bounded curve_id on top, so the EC opcodes spend most fuzz iterations past +// the initial validation gate. +type ecCurveCaseBuilder struct{ coordPushes int } + +func (b ecCurveCaseBuilder) Build(data []byte, world *opcodeFuzzWorld) opcodeFuzzCase { + c := defaultCaseBuilder{}.Build(data, world) + salted := saltedBytes(data, 0xec) + items := make([][]byte, 0, b.coordPushes+1) + for i := range b.coordPushes { + items = append(items, ecFuzzCoord(salted, byte(i))) + } + curveID := scriptNum(int64(salted[0]) % 5) // bias toward 0..2 valid, 3..4 invalid + items = append(items, curveID.Bytes()) + c.stackPushes = items + return c +} + +// ecPairingFuzzCaseBuilder pushes a count near the maxECPairingCount boundary +// and per-pair coordinates, exercising the variable-arity stack consumption +// in OP_ECPAIRING. +type ecPairingFuzzCaseBuilder struct{} + +func (ecPairingFuzzCaseBuilder) Build(data []byte, world *opcodeFuzzWorld) opcodeFuzzCase { + c := defaultCaseBuilder{}.Build(data, world) + salted := saltedBytes(data, 0xed) + // 0..19 covers below, at, and just past maxECPairingCount = 16 + pairCount := int64(salted[0]) % 20 + items := make([][]byte, 0, 6*int(pairCount)+2) + for i := int64(0); i < pairCount; i++ { + for j := byte(0); j < 6; j++ { + items = append(items, ecFuzzCoord(salted, byte(i)*7+j)) + } + } + items = append(items, scriptNum(pairCount).Bytes()) + curveID := scriptNum(int64(salted[1]) % 5) + items = append(items, curveID.Bytes()) + c.stackPushes = items + return c +} + +// ecFuzzCoord builds a random-but-canonical BigNum byte slice from the fuzz +// seed so most fuzz inputs survive minimal-encoding checks and let the opcode +// reach its curve-specific validation paths. +func ecFuzzCoord(seed []byte, salt byte) []byte { + src := saltedBytes(seed, salt) + // Length 0..32 bytes covers small, medium, and full-32-byte coordinates. + length := int(src[0]) % 33 + if length == 0 { + return nil + } + raw := make([]byte, length) + copy(raw, src[1:]) + // Drop sign bit on the last byte so the BigNum is non-negative + // most of the time. Coordinates are non-negative in the EC opcodes. + raw[length-1] &= 0x7f + canonical := minimallyEncode(raw) + if canonical == nil { + return nil + } + out := make([]byte, len(canonical)) + copy(out, canonical) + return out } // FuzzOpcodes turns one fuzz input into a coherent transaction world, derives a diff --git a/pkg/arkade/opcode_test.go b/pkg/arkade/opcode_test.go index 98d6ccb..861e045 100644 --- a/pkg/arkade/opcode_test.go +++ b/pkg/arkade/opcode_test.go @@ -310,9 +310,9 @@ var opcodeSpecs = [256]*opcodeSpec{ OP_UNKNOWN221: invalidSpec(OP_UNKNOWN221), OP_UNKNOWN222: invalidSpec(OP_UNKNOWN222), OP_UNKNOWN223: invalidSpec(OP_UNKNOWN223), - OP_UNKNOWN224: invalidSpec(OP_UNKNOWN224), - OP_UNKNOWN225: invalidSpec(OP_UNKNOWN225), - OP_UNKNOWN226: invalidSpec(OP_UNKNOWN226), + OP_ECADD: ecAddSpec(), + OP_ECMUL: ecMulSpec(), + OP_ECPAIRING: ecPairingSpec(), OP_ECMULSCALARVERIFY: ecmulScalarVerifySpec(), OP_TWEAKVERIFY: tweakVerifySpec(), OP_INSPECTNUMASSETGROUPS: assetSpec(OP_INSPECTNUMASSETGROUPS), diff --git a/test/csfs_ecdsa_secp256r1_test.go b/test/csfs_ecdsa_secp256r1_test.go new file mode 100644 index 0000000..c81b651 --- /dev/null +++ b/test/csfs_ecdsa_secp256r1_test.go @@ -0,0 +1,295 @@ +package test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "math/big" + "testing" + + "github.com/ArkLabsHQ/introspector/pkg/arkade" + "github.com/arkade-os/arkd/pkg/ark-lib/offchain" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// Oracle message format used in the test: 16 bytes total, prefixed with a +// 4-byte magic tag identifying the message kind. The 12 trailing bytes are +// the payload (timestamp, price, …) — the script doesn't introspect them, +// it just hashes the whole thing. +const ( + oracleMessageLen = 16 +) + +var oracleMessageMagic = []byte("ORCL") + +// TestCSFSEmulationECDSASecp256r1 emulates OP_CHECKSIGFROMSTACK for an ECDSA +// signature over secp256r1, built only out of OP_ECMUL / OP_ECADD. The +// verifying public key (Px, Py) is committed statically in the script. The +// raw oracle message is provided in the witness, hashed inside the script, +// and the script enforces a minimal envelope (length + magic tag) before +// running ECDSA verification on the digest. This binds the signature to a +// specific structured message — without that binding the witness-hinted +// ECDSA verifier accepts public-data-only forgeries. +// +// ECDSA verification with witness shortcuts: +// +// z = BIN2NUM(SHA256(m) ‖ 0x00) ← computed in-script, see below +// u1 = z · s⁻¹ (mod n) ← supplied as a witness hint +// u2 = r · s⁻¹ (mod n) ← supplied as a witness hint +// R = u1·G + u2·P +// accept iff R.x mod n == r +// +// The two `EQUALVERIFY` lines (u1·s ≡ z, u2·s ≡ r) pin u1, u2 uniquely to +// (r, s, z), replacing the modular inversion. +// +// Hash convention: arkade BigNums are sign-magnitude little-endian, so +// `OP_BIN2NUM` reads the SHA-256 output as an LE integer (the byte-reversed +// big-endian integer). A 0x00 byte is appended before BIN2NUM so the result +// is always positive regardless of whether the top hash byte's high bit is +// set. The Go-side signer must use the same convention. +func TestCSFSEmulationECDSASecp256r1(t *testing.T) { + ctx := t.Context() + + alice, _, _, grpcAlice := setupArkSDKwithPublicKey(t) + t.Cleanup(func() { + grpcAlice.Close() + }) + + introspectorClient, introspectorPubKey, conn := setupIntrospectorClient(t, ctx) + t.Cleanup(func() { + //nolint:errcheck + conn.Close() + }) + + aliceAddr := fundAndSettleAlice(t, ctx, alice, 100_000) + indexerSvc := setupIndexer(t) + + infos, err := grpcAlice.GetInfo(ctx) + require.NoError(t, err) + checkpointScriptBytes, err := hex.DecodeString(infos.CheckpointTapscript) + require.NoError(t, err) + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // A well-formed oracle message: 4-byte tag + 12-byte payload. + message := append([]byte{}, oracleMessageMagic...) + message = append(message, []byte("price=42000\x00")...) + require.Len(t, message, oracleMessageLen) + + r, s := signOracleMessage(t, priv, message) + u1, u2 := ecdsaHints(t, message, r, s) + + px, py := p256PubKeyCoords(t, &priv.PublicKey) + arkadeScript := ecdsaSecp256r1VerifyScript(t, px, py) + arkadeScriptHash := arkade.ArkadeScriptHash(arkadeScript) + + vtxoScript := createArkadeOnlyVtxoScript(aliceAddr.Signer, introspectorPubKey, arkadeScriptHash) + + const contractAmount = int64(10_000) + vtxoInput := fund(t, ctx, alice, indexerSvc, aliceAddr.Signer, vtxoScript, contractAmount) + + // Witness pushed in order; m ends up on top of the stack: [r, u1, u2, s, m]. + validWitness := wire.TxWitness{ + bnBytes(r), + bnBytes(u1), + bnBytes(u2), + bnBytes(s), + message, + } + + receiverPkScript := randomP2TR(t) + + buildSpend := func(w wire.TxWitness) (*psbt.Packet, []*psbt.Packet) { + spendTx, checkpoints, err := offchain.BuildTxs( + []offchain.VtxoInput{vtxoInput}, + []*wire.TxOut{{Value: contractAmount, PkScript: receiverPkScript}}, + checkpointScriptBytes, + ) + require.NoError(t, err) + addIntrospectorPacket(t, spendTx, []arkade.IntrospectorEntry{ + {Vin: 0, Script: arkadeScript, Witness: w}, + }) + return spendTx, checkpoints + } + + t.Run("valid_signature_accepted", func(t *testing.T) { + spendTx, checkpoints := buildSpend(validWitness) + + waitForVtxos := watchForPreconfirmedVtxos(t, indexerSvc, spendTx, 0) + + encoded, err := spendTx.B64Encode() + require.NoError(t, err) + + _, _, err = introspectorClient.SubmitTx(ctx, encoded, encodeCheckpoints(t, checkpoints)) + require.NoError(t, err) + waitForVtxos() + }) + + t.Run("tampered_signature_rejected", func(t *testing.T) { + // Flip the lowest bit of s. Recompute u1, u2 against the tampered s so + // the two scalar-relation EQUALVERIFY lines still pass; the final R.x + // check is what must reject. + sBad := new(big.Int).Xor(s, big.NewInt(1)) + u1Bad, u2Bad := ecdsaHintsExplicit(t, message, r, sBad) + + spendTx, checkpoints := buildSpend(wire.TxWitness{ + bnBytes(r), bnBytes(u1Bad), bnBytes(u2Bad), bnBytes(sBad), message, + }) + encoded, err := spendTx.B64Encode() + require.NoError(t, err) + + _, _, err = introspectorClient.SubmitTx(ctx, encoded, encodeCheckpoints(t, checkpoints)) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to process transaction") + }) + + t.Run("wrong_message_magic_rejected", func(t *testing.T) { + // Same key, same length, but the magic tag mismatches. + badMsg := append([]byte("XXXX"), message[4:]...) + + spendTx, checkpoints := buildSpend(wire.TxWitness{ + bnBytes(r), bnBytes(u1), bnBytes(u2), bnBytes(s), badMsg, + }) + encoded, err := spendTx.B64Encode() + require.NoError(t, err) + + _, _, err = introspectorClient.SubmitTx(ctx, encoded, encodeCheckpoints(t, checkpoints)) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to process transaction") + }) +} + +// signOracleMessage signs `m` under priv using ECDSA over P-256 with the +// LE-of-SHA256 digest convention the script expects. +func signOracleMessage(t *testing.T, priv *ecdsa.PrivateKey, m []byte) (r, s *big.Int) { + t.Helper() + digest := leDigest(m) + r, s, err := ecdsa.Sign(rand.Reader, priv, digest) + require.NoError(t, err) + return r, s +} + +// ecdsaHints returns the (u1, u2) witness shortcuts for (r, s, z) where z is +// derived from m the same way the script will derive it. +func ecdsaHints(t *testing.T, m []byte, r, s *big.Int) (u1, u2 *big.Int) { + return ecdsaHintsExplicit(t, m, r, s) +} + +func ecdsaHintsExplicit(t *testing.T, m []byte, r, s *big.Int) (u1, u2 *big.Int) { + t.Helper() + n := elliptic.P256().Params().N + z := new(big.Int).SetBytes(leDigest(m)) + sInv := new(big.Int).ModInverse(s, n) + require.NotNil(t, sInv) + u1 = new(big.Int).Mod(new(big.Int).Mul(z, sInv), n) + u2 = new(big.Int).Mod(new(big.Int).Mul(r, sInv), n) + return u1, u2 +} + +// p256PubKeyCoords pulls (X, Y) out of an ECDSA P-256 public key via the +// new SEC1 uncompressed encoding API. Direct access to `pub.X` / `pub.Y` is +// deprecated since Go 1.26. +func p256PubKeyCoords(t *testing.T, pub *ecdsa.PublicKey) (px, py *big.Int) { + t.Helper() + enc, err := pub.Bytes() + require.NoError(t, err) + // SEC1 uncompressed: 0x04 || X (32 bytes) || Y (32 bytes) for P-256. + require.Len(t, enc, 65) + require.Equal(t, byte(0x04), enc[0]) + return new(big.Int).SetBytes(enc[1:33]), new(big.Int).SetBytes(enc[33:65]) +} + +// leDigest returns SHA256(m) byte-reversed, so when fed to ecdsa.Sign / +// SetBytes (which read big-endian) it represents the same integer that the +// in-script `BIN2NUM(SHA256(m) ‖ 0x00)` produces. +func leDigest(m []byte) []byte { + h := sha256.Sum256(m) + out := make([]byte, len(h)) + for i := range h { + out[i] = h[len(h)-1-i] + } + return out +} + +// ecdsaSecp256r1VerifyScript builds the verifier. The verifying public key +// (px, py) is committed to as inline data pushes; only signatures by that +// key on a well-formed oracle message satisfy the script. +// +// Witness order on the stack (top right): +// +// [r, u1, u2, s, m] +// +// Stack diagrams use top-on-right convention. +func ecdsaSecp256r1VerifyScript(t *testing.T, px, py *big.Int) []byte { + t.Helper() + + params := elliptic.P256().Params() + nBytes := bnBytes(params.N) + gxBytes := bnBytes(params.Gx) + gyBytes := bnBytes(params.Gy) + pxBytes := bnBytes(px) + pyBytes := bnBytes(py) + + out, err := txscript.NewScriptBuilder(). + // Stack: [r, u1, u2, s, m] + // + // A) Envelope checks on the oracle message. + AddOp(arkade.OP_SIZE). // [..., m, len(m)] + AddInt64(oracleMessageLen). // [..., m, len(m), 16] + AddOp(arkade.OP_EQUALVERIFY). // [..., m] + AddOp(arkade.OP_DUP). // [..., m, m] + AddInt64(int64(len(oracleMessageMagic))). // [..., m, m, 4] + AddOp(arkade.OP_LEFT). // [..., m, m[:4]] + AddData(oracleMessageMagic). // [..., m, m[:4], "ORCL"] + AddOp(arkade.OP_EQUALVERIFY). // [..., m] + // + // B) Hash the message and convert to a positive BigNum digest z. + // 0x00 sign-extension byte makes BIN2NUM treat the result as positive + // regardless of the high bit of SHA-256's last byte. + AddOp(arkade.OP_SHA256). // [..., h] (32 BE bytes) + AddData([]byte{0x00}). // [..., h, 0x00] + AddOp(arkade.OP_CAT). // [..., h‖0x00] (33 bytes, positive) + AddOp(arkade.OP_BIN2NUM). // [r, u1, u2, s, z] + // + // C) Verify u1·s ≡ z (mod n). + AddOp(arkade.OP_OVER). // [..., s, z, s] + AddOp(arkade.OP_4).AddOp(arkade.OP_PICK). // [..., s, z, s, u1] + AddOp(arkade.OP_MUL). // [..., s, z, u1·s] + AddData(nBytes).AddOp(arkade.OP_MOD). // [..., s, z, (u1·s) mod n] + AddOp(arkade.OP_SWAP). // [..., s, (u1·s) mod n, z] + AddData(nBytes).AddOp(arkade.OP_MOD). // [..., s, (u1·s) mod n, z mod n] + AddOp(arkade.OP_EQUALVERIFY). // [r, u1, u2, s] + // + // D) Verify u2·s ≡ r (mod n). + AddOp(arkade.OP_OVER). // [..., u2, s, u2] + AddOp(arkade.OP_MUL). // [..., u2, u2·s] + AddData(nBytes).AddOp(arkade.OP_MOD). // [..., u2, (u2·s) mod n] + AddOp(arkade.OP_3).AddOp(arkade.OP_PICK). // [..., u2, (u2·s) mod n, r] + AddData(nBytes).AddOp(arkade.OP_MOD). // [..., u2, (u2·s) mod n, r mod n] + AddOp(arkade.OP_EQUALVERIFY). // [r, u1, u2] + // + // E) u1·G on secp256r1. + AddData(gxBytes).AddData(gyBytes). // [..., r, u1, u2, Gx, Gy] + AddOp(arkade.OP_3).AddOp(arkade.OP_ROLL). // [..., r, u2, Gx, Gy, u1] + AddOp(arkade.OP_1).AddOp(arkade.OP_ECMUL). // [r, u2, u1Gx, u1Gy] + // + // F) u2·P on secp256r1 (Px, Py committed in the script). + AddData(pxBytes).AddData(pyBytes). // [..., r, u2, u1Gx, u1Gy, Px, Py] + AddOp(arkade.OP_4).AddOp(arkade.OP_ROLL). // [..., r, u1Gx, u1Gy, Px, Py, u2] + AddOp(arkade.OP_1).AddOp(arkade.OP_ECMUL). // [r, u1Gx, u1Gy, u2Px, u2Py] + // + // G) R = u1·G + u2·P, accept iff R.x mod n == r. + AddOp(arkade.OP_1).AddOp(arkade.OP_ECADD). // [r, Rx, Ry] + AddOp(arkade.OP_DROP). // [r, Rx] + AddData(nBytes).AddOp(arkade.OP_MOD). // [r, Rx mod n] + AddOp(arkade.OP_EQUAL). // [bool] + Script() + require.NoError(t, err) + return out +} diff --git a/test/groth16_bn254_test.go b/test/groth16_bn254_test.go new file mode 100644 index 0000000..f559dff --- /dev/null +++ b/test/groth16_bn254_test.go @@ -0,0 +1,351 @@ +package test + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/ArkLabsHQ/introspector/pkg/arkade" + "github.com/arkade-os/arkd/pkg/ark-lib/offchain" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + gnarkbn254 "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/stretchr/testify/require" +) + +const groth16BN254PublicInput = int64(9) + +type bn254G1Point struct { + x *big.Int + y *big.Int +} + +type bn254G2Point struct { + xC0 *big.Int + xC1 *big.Int + yC0 *big.Int + yC1 *big.Int +} + +type groth16BN254Fixture struct { + ic0 bn254G1Point + ic1 bn254G1Point + alpha bn254G1Point + betaNeg bn254G2Point + gammaNeg bn254G2Point + deltaNeg bn254G2Point + + proofA bn254G1Point + proofB bn254G2Point + proofC bn254G1Point +} + +// TestGroth16BN254VerificationInScript verifies the Groth16 verifier equation +// in Arkade Script: +// +// e(A, B) * e(C, -delta) * e(vk_x, -gamma) * e(alpha, -beta) == 1 +// +// The script computes vk_x = IC0 + public_input*IC1 with OP_ECMUL and +// OP_ECADD, then checks the four-pair product with OP_ECPAIRING. +func TestGroth16BN254VerificationInScript(t *testing.T) { + ctx := t.Context() + + fixture := newGroth16BN254Fixture(t) + requireGroth16BN254Pairing(t, fixture, groth16BN254PublicInput, fixture.proofC, true) + + alice, _, _, grpcAlice := setupArkSDKwithPublicKey(t) + t.Cleanup(func() { + grpcAlice.Close() + }) + + introspectorClient, introspectorPubKey, conn := setupIntrospectorClient(t, ctx) + t.Cleanup(func() { + //nolint:errcheck + conn.Close() + }) + + aliceAddr := fundAndSettleAlice(t, ctx, alice, 100_000) + indexerSvc := setupIndexer(t) + + infos, err := grpcAlice.GetInfo(ctx) + require.NoError(t, err) + checkpointScriptBytes, err := hex.DecodeString(infos.CheckpointTapscript) + require.NoError(t, err) + + arkadeScript := groth16BN254VerifierScript(t, fixture) + arkadeScriptHash := arkade.ArkadeScriptHash(arkadeScript) + + vtxoScript := createArkadeOnlyVtxoScript(aliceAddr.Signer, introspectorPubKey, arkadeScriptHash) + + const contractAmount = int64(10_000) + vtxoInput := fund(t, ctx, alice, indexerSvc, aliceAddr.Signer, vtxoScript, contractAmount) + receiverPkScript := randomP2TR(t) + + buildSpend := func(w wire.TxWitness) (*psbt.Packet, []*psbt.Packet) { + spendTx, checkpoints, err := offchain.BuildTxs( + []offchain.VtxoInput{vtxoInput}, + []*wire.TxOut{{Value: contractAmount, PkScript: receiverPkScript}}, + checkpointScriptBytes, + ) + require.NoError(t, err) + + addIntrospectorPacket(t, spendTx, []arkade.IntrospectorEntry{ + {Vin: 0, Script: arkadeScript, Witness: w}, + }) + return spendTx, checkpoints + } + + t.Run("wrong_public_input_rejected", func(t *testing.T) { + witness := groth16BN254Witness(fixture, groth16BN254PublicInput+1, fixture.proofC) + spendTx, checkpoints := buildSpend(witness) + + err := executeArkadeScripts(t, spendTx, checkpoints, introspectorPubKey) + require.Error(t, err) + require.Contains(t, err.Error(), "false stack entry at end of script execution") + + encoded, err := spendTx.B64Encode() + require.NoError(t, err) + + _, _, err = introspectorClient.SubmitTx(ctx, encoded, encodeCheckpoints(t, checkpoints)) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to process transaction") + }) + + t.Run("tampered_proof_rejected", func(t *testing.T) { + tamperedC := bn254G1Add(fixture.proofC, fixture.ic1) + requireGroth16BN254Pairing(t, fixture, groth16BN254PublicInput, tamperedC, false) + + witness := groth16BN254Witness(fixture, groth16BN254PublicInput, tamperedC) + spendTx, checkpoints := buildSpend(witness) + + err := executeArkadeScripts(t, spendTx, checkpoints, introspectorPubKey) + require.Error(t, err) + require.Contains(t, err.Error(), "false stack entry at end of script execution") + }) + + t.Run("valid_proof_accepted", func(t *testing.T) { + witness := groth16BN254Witness(fixture, groth16BN254PublicInput, fixture.proofC) + spendTx, checkpoints := buildSpend(witness) + + require.NoError(t, executeArkadeScripts(t, spendTx, checkpoints, introspectorPubKey)) + + waitForVtxos := watchForPreconfirmedVtxos(t, indexerSvc, spendTx, 0) + + encoded, err := spendTx.B64Encode() + require.NoError(t, err) + + _, _, err = introspectorClient.SubmitTx(ctx, encoded, encodeCheckpoints(t, checkpoints)) + require.NoError(t, err) + waitForVtxos() + }) +} + +func newGroth16BN254Fixture(t *testing.T) groth16BN254Fixture { + t.Helper() + + // Static gnark Groth16 BN254 fixture for the circuit Y*Y = X with public + // input X=9 and private witness Y=3. + return groth16BN254Fixture{ + ic0: g1Hex( + t, + "214762f5e1b31936df442f16298fdc668254fe4d3c13f92d8c0b0988aabd869d", + "1413a9ea941737df505a163fe8469f445791833e637a0daaf82849deee477c9f", + ), + ic1: g1Hex( + t, + "a671871e2e742344d42f7317f15020c8ffd06b9a6d5fc2604effb253ea63140", + "27b737a8668cf74d50fc8a41387b3d15de347cd6af0698b385c4f8611459faec", + ), + alpha: g1Hex( + t, + "84af1dd3073d98496ae82b47b686deeab8520d014edfb1d4b89c6bc9815e7e4", + "1baf84027fc4c511a8e6fde1bf178f7f4142c5847c7cf08be0ef48c3de402941", + ), + betaNeg: g2Hex( + t, + "1d6e87de4fa9d755e73537d1016fa6bf5e6314154b1cc29c15f525fbc6b74ce", + "1efd2d59f1887688bd7eaa5e7fa318e3b7855916fdc7d28c219ea89b4fb6bbc3", + "467f0733b737da7d9de233a0c3461530a746263f1f4823fed0240d2ca313cc7", + "16fe7eba648bf65b33187de2872b48c01f70b7d4c63292e7c9d882ee34e820a6", + ), + gammaNeg: g2Hex( + t, + "1f66abcb6a97665b70301df80c6c117895bf0d805a4ec298159df4be9a9e4afd", + "1e8f987221464dbe10ca749d5d30a012a8a29a88cc59dea49f198e412fc2bd7c", + "eb34da773a8038d2c0888b9516db9a3030999d84d5218eb93163293970eaa30", + "2c63398cabafdcf2ec24ef25190420153b5db9536f95bc98fbe2c4ebf4b7788a", + ), + deltaNeg: g2Hex( + t, + "103420825ef79a5c489e93d5e686988fdd35565f906873a0a76d95feddd0e613", + "2cddd0828035c3469a02325690251bf93c962b04a2de6679a5c793802ce85795", + "2e0ce45661beb79b08d8f6a50a505223a5b83e963afe98c65542bfaed2bb8c6d", + "1e125142c038ef6508779d59fc45bd0fe8a779656e090411b704373e7b708d30", + ), + proofA: g1Hex( + t, + "288965af2fd92b46c200c6486f4d3d2d9853b43006a939487265ca003dae3d1a", + "b74788ac234aab5cf97938435bc4a2e1038af3ecd6147c49d02a7621cc64491", + ), + proofB: g2Hex( + t, + "2614fad1fd4c641ef5b29564ec1b06a18bbeed7b0a8af8b1d646ba467dcff714", + "decd8fba51fd1505cb39610ed8cc87918bb6e3d9aab7103b9ca1313d744b428", + "5200fcb224cc519810eed7af57d6f58a57a49bc52aa43577e0dd5d79c8e8361", + "2ada81fd1ec597c95138d4ccecaf7d0355116e550043cd373b7fd711f7c96a14", + ), + proofC: g1Hex( + t, + "2776c430308e75b457828e0b5514b0ba6e99311a8509fa8673aad885714ab569", + "78c80a2a5bbc6c24fc811086b1e021b1aa58a943006992ee23dc5214da3303", + ), + } +} + +func groth16BN254Witness(f groth16BN254Fixture, publicInput int64, proofC bn254G1Point) wire.TxWitness { + return wire.TxWitness{ + bnBytes(f.proofA.x), + bnBytes(f.proofA.y), + bnBytes(f.proofB.xC1), + bnBytes(f.proofB.xC0), + bnBytes(f.proofB.yC1), + bnBytes(f.proofB.yC0), + bnBytes(proofC.x), + bnBytes(proofC.y), + bnBytes(big.NewInt(publicInput)), + } +} + +func groth16BN254VerifierScript(t *testing.T, f groth16BN254Fixture) []byte { + t.Helper() + + builder := txscript.NewScriptBuilder() + + // Witness stack, top on right: + // [A_x, A_y, B_x_c1, B_x_c0, B_y_c1, B_y_c0, C_x, C_y, public_input] + addG1(builder, f.ic1) + builder. + AddOp(arkade.OP_2).AddOp(arkade.OP_ROLL). + AddInt64(arkade.CurveAltBN128).AddOp(arkade.OP_ECMUL) + addG1(builder, f.ic0) + builder.AddInt64(arkade.CurveAltBN128).AddOp(arkade.OP_ECADD) + + // Save computed vk_x while completing the pairing stack. The witness proof + // points already form the first pair (A, B) and G1 side of the second pair + // (C, -delta). + builder.AddOp(arkade.OP_TOALTSTACK).AddOp(arkade.OP_TOALTSTACK) + addG2(builder, f.deltaNeg) + builder.AddOp(arkade.OP_FROMALTSTACK).AddOp(arkade.OP_FROMALTSTACK) + addG2(builder, f.gammaNeg) + addG1(builder, f.alpha) + addG2(builder, f.betaNeg) + + script, err := builder. + AddInt64(4). + AddInt64(arkade.CurveAltBN128). + AddOp(arkade.OP_ECPAIRING). + Script() + require.NoError(t, err) + return script +} + +func addG1(builder *txscript.ScriptBuilder, p bn254G1Point) { + builder.AddData(bnBytes(p.x)).AddData(bnBytes(p.y)) +} + +func addG2(builder *txscript.ScriptBuilder, p bn254G2Point) { + builder. + AddData(bnBytes(p.xC1)). + AddData(bnBytes(p.xC0)). + AddData(bnBytes(p.yC1)). + AddData(bnBytes(p.yC0)) +} + +func requireGroth16BN254Pairing( + t *testing.T, f groth16BN254Fixture, publicInput int64, proofC bn254G1Point, expected bool, +) { + t.Helper() + + vkX := bn254G1Add( + bn254G1Mul(f.ic1, big.NewInt(publicInput)), + f.ic0, + ) + + ok, err := gnarkbn254.PairingCheck( + []gnarkbn254.G1Affine{ + toGnarkG1(f.proofA), + toGnarkG1(proofC), + toGnarkG1(vkX), + toGnarkG1(f.alpha), + }, + []gnarkbn254.G2Affine{ + toGnarkG2(f.proofB), + toGnarkG2(f.deltaNeg), + toGnarkG2(f.gammaNeg), + toGnarkG2(f.betaNeg), + }, + ) + require.NoError(t, err) + require.Equal(t, expected, ok) +} + +func g1Hex(t *testing.T, x, y string) bn254G1Point { + t.Helper() + return bn254G1Point{x: bigHex(t, x), y: bigHex(t, y)} +} + +func g2Hex(t *testing.T, xC0, xC1, yC0, yC1 string) bn254G2Point { + t.Helper() + return bn254G2Point{ + xC0: bigHex(t, xC0), + xC1: bigHex(t, xC1), + yC0: bigHex(t, yC0), + yC1: bigHex(t, yC1), + } +} + +func bigHex(t *testing.T, s string) *big.Int { + t.Helper() + v, ok := new(big.Int).SetString(s, 16) + require.True(t, ok, "invalid hex bigint %q", s) + return v +} + +func bn254G1Mul(p bn254G1Point, scalar *big.Int) bn254G1Point { + in := toGnarkG1(p) + var out gnarkbn254.G1Affine + out.ScalarMultiplication(&in, scalar) + return fromGnarkG1(out) +} + +func bn254G1Add(a, b bn254G1Point) bn254G1Point { + ga := toGnarkG1(a) + gb := toGnarkG1(b) + var out gnarkbn254.G1Affine + out.Add(&ga, &gb) + return fromGnarkG1(out) +} + +func toGnarkG1(p bn254G1Point) gnarkbn254.G1Affine { + var out gnarkbn254.G1Affine + out.X.SetBigInt(p.x) + out.Y.SetBigInt(p.y) + return out +} + +func fromGnarkG1(p gnarkbn254.G1Affine) bn254G1Point { + var x, y big.Int + p.X.BigInt(&x) + p.Y.BigInt(&y) + return bn254G1Point{x: new(big.Int).Set(&x), y: new(big.Int).Set(&y)} +} + +func toGnarkG2(p bn254G2Point) gnarkbn254.G2Affine { + var out gnarkbn254.G2Affine + out.X.A0.SetBigInt(p.xC0) + out.X.A1.SetBigInt(p.xC1) + out.Y.A0.SetBigInt(p.yC0) + out.Y.A1.SetBigInt(p.yC1) + return out +} diff --git a/test/utils_test.go b/test/utils_test.go index ff68a93..1d996fa 100644 --- a/test/utils_test.go +++ b/test/utils_test.go @@ -7,6 +7,7 @@ import ( "encoding/binary" "encoding/hex" "fmt" + "math/big" "slices" "strings" "testing" @@ -1186,6 +1187,28 @@ func debugScriptExecution(t *testing.T) arkade.ExecuteOption { ) } +// bnBytes returns the canonical Arkade BigNum encoding of v +// (sign-magnitude little-endian, with a 0x00/0x80 sign-extension byte added +// only when the high bit of the magnitude's MSB would otherwise collide with +// the sign bit). Zero encodes as the empty slice. +func bnBytes(v *big.Int) []byte { + if v.Sign() == 0 { + return nil + } + out := new(big.Int).Abs(v).Bytes() + slices.Reverse(out) + if out[len(out)-1]&0x80 != 0 { + extra := byte(0x00) + if v.Sign() < 0 { + extra = 0x80 + } + out = append(out, extra) + } else if v.Sign() < 0 { + out[len(out)-1] |= 0x80 + } + return out +} + // randomP2TRScript returns a fresh P2TR scriptPubKey. Used for destinations // where the identity is irrelevant to the test. func randomP2TRScript(t *testing.T) []byte {