diff --git a/bindings/go/.github/workflows/go.yml b/bindings/go/.github/workflows/go.yml new file mode 100644 index 00000000..2104de40 --- /dev/null +++ b/bindings/go/.github/workflows/go.yml @@ -0,0 +1,154 @@ +name: Go + +on: + push: + branches: [main, feat/**] + paths: + - 'bindings/go/**' + - 'ows/crates/ows-lib/**' + - 'ows/crates/ows-core/**' + - 'ows/crates/ows-signer/**' + pull_request: + paths: + - 'bindings/go/**' + - 'ows/crates/ows-lib/**' + - 'ows/crates/ows-core/**' + - 'ows/crates/ows-signer/**' + +env: + CARGO_TERM_COLOR: always + +jobs: + go-bindings: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rustup@latest + with: + toolchain: stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: bindings/go + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('bindings/go/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Build Rust FFI library + working-directory: bindings/go + run: cargo build --release -p ows-go + + - name: Clippy + working-directory: bindings/go + run: cargo clippy -p ows-go -- -D warnings + + - name: Run Go tests + working-directory: bindings/go + run: | + export CGO_ENABLED=1 + export CGO_LDFLAGS="-L$(pwd)/target/release -lows_go" + export LD_LIBRARY_PATH="$(pwd)/target/release${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + go test -v ./ows/... + + - name: Format Go code + working-directory: bindings/go + run: | + go fmt ./ows/... + gofmt -w ./examples/demo.go + git diff --exit-code + + - name: Vet Go code + working-directory: bindings/go + run: go vet ./ows/... + + go-bindings-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rustup@latest + with: + toolchain: stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: bindings/go + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build Rust FFI library + working-directory: bindings/go + run: cargo build --release -p ows-go + + - name: Clippy + working-directory: bindings/go + run: cargo clippy -p ows-go -- -D warnings + + - name: Run Go tests + working-directory: bindings/go + shell: pwsh + run: | + $release = (Resolve-Path .\target\release).Path + $env:CGO_ENABLED = "1" + $env:CGO_LDFLAGS = "-L$($release -replace '\\','/') -lows_go" + $env:PATH = "$release;$env:PATH" + go test -v ./ows/... + + go-bindings-macos: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rustup@latest + with: + toolchain: stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: bindings/go + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build Rust FFI library + working-directory: bindings/go + run: cargo build --release -p ows-go + + - name: Clippy + working-directory: bindings/go + run: cargo clippy -p ows-go -- -D warnings + + - name: Run Go tests + working-directory: bindings/go + run: | + export CGO_ENABLED=1 + export CGO_LDFLAGS="-L$(pwd)/target/release -lows_go" + export DYLD_LIBRARY_PATH="$(pwd)/target/release${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" + go test -v ./ows/... diff --git a/bindings/go/.gitignore b/bindings/go/.gitignore new file mode 100644 index 00000000..c9516ac3 --- /dev/null +++ b/bindings/go/.gitignore @@ -0,0 +1,24 @@ +# Rust build artifacts +/target/ +**/*.rs.bk +Cargo.lock +*.pdb + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Test artifacts +*.prof +*.sym + +# Go +*.exe +*.test +*.out + +# OS +.DS_Store +Thumbs.db diff --git a/bindings/go/Cargo.toml b/bindings/go/Cargo.toml new file mode 100644 index 00000000..dbb615d4 --- /dev/null +++ b/bindings/go/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ows-go" +version = "1.2.0" +edition = "2021" +description = "Go native bindings for the Open Wallet Standard via C FFI" +license = "MIT" +repository = "https://github.com/open-wallet-standard/core" +readme = "README.md" + +[lib] +name = "ows_go" +crate-type = ["cdylib"] + +[features] +default = [] +fast-kdf = ["ows-lib/fast-kdf"] + +[dependencies] +ows-lib = { path = "../../ows/crates/ows-lib" } diff --git a/bindings/go/README.md b/bindings/go/README.md new file mode 100644 index 00000000..571f3f9c --- /dev/null +++ b/bindings/go/README.md @@ -0,0 +1,168 @@ +# Go Bindings for Open Wallet Standard (OWS) v1 + +Minimal Go bindings via cgo + Rust FFI. + +## Build Prerequisites + +1. **Rust toolchain** (stable) +2. **Go 1.21+** + +## Build Steps + +The Go package is at `bindings/go/ows/` (import path `github.com/open-wallet-standard/core/bindings/go/ows`). +The Rust FFI crate is at `bindings/go/` (crate name `ows-go`). + +```bash +# Clone the repository +git clone https://github.com/open-wallet-standard/core.git +cd core + +# 1. Build the Rust FFI library +cd bindings/go +cargo build --release -p ows-go + +# 2. Build the Go package +# Linux/macOS +export CGO_ENABLED=1 +export CGO_LDFLAGS="-L$(pwd)/target/release -lows_go" +export LD_LIBRARY_PATH="$(pwd)/target/release${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" # Linux +export DYLD_LIBRARY_PATH="$(pwd)/target/release${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" # macOS +go build ./ows/... + +# Windows (PowerShell) +$release = (Resolve-Path .\target\release).Path +$env:CGO_ENABLED="1" +$env:CGO_LDFLAGS="-L$($release -replace '\\','/') -lows_go" +$env:PATH="$release;$env:PATH" +go build ./ows/... + +# 3. Run tests (from bindings/go, after building the Rust library) +go test -v ./ows/... + +# 4. Run the example program (from bindings/go) +go run ./examples/demo.go +``` + +## Public API (v1 scope) + +### Wallet Operations + +```go +// CreateWallet creates a new wallet with addresses for all supported chains. +// words: 12 or 24 (use 0 for default 12). +// vaultPath: "" uses default ~/.ows; pass a temp path for isolated tests. +wi, err := ows.CreateWallet("my-wallet", "hunter2", 12, vaultPath) +wi.Name, wi.ID, wi.Accounts[].ChainID, wi.Accounts[].Address + +// ListWallets returns all wallets in the vault. +wallets, err := ows.ListWallets(vaultPath) +``` + +### Signing Operations + +```go +// SignMessage signs a UTF-8 message on behalf of a wallet. +// chain: "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui", "spark", "filecoin" +// encoding: "" or "utf8" for UTF-8 input, or "hex" for hex-decoded bytes +// index: use ows.IndexNone to default to account 0. +// Returns SignResult { Signature: string, RecoveryID: *uint8 (nil for Ed25519 chains) } +sr, err := ows.SignMessage("my-wallet", "evm", "hello world", "hunter2", "", ows.IndexNone, vaultPath) +sr.Signature // hex-encoded signature +sr.RecoveryID // present for secp256k1 chains (evm/btc/cosmos/tron/spark/filecoin); nil for Ed25519 (solana/ton/sui) + +// SignTx signs a raw transaction. +// txHex: hex-encoded transaction bytes (0x prefix optional). +// Returns the same SignResult shape as SignMessage. +sr, err := ows.SignTx("my-wallet", "evm", "f86c08...", "hunter2", ows.IndexNone, vaultPath) +``` + +### Error Handling + +```go +var err error + +// Check for specific error conditions. +if ows.IsWalletNotFound(err) { + // wallet does not exist +} +if ows.IsSignerError(err) { + // signer-layer failure +} +``` + +### Encoding Expectations + +| Function | Input format | Notes | +|---|---|---| +| `SignMessage` | UTF-8 string or hex-decoded bytes | Use `""`/`"utf8"` for UTF-8 input or `"hex"` for hex input. | +| `SignTx` | Hex-encoded bytes | No 0x prefix required. Chain determines transaction format. | + +### Returned Signature Fields + +`SignResult` returned by both `SignMessage` and `SignTx`: + +```go +type SignResult struct { + Signature string // hex-encoded signature + RecoveryID *uint8 // present for secp256k1 chains; nil for Ed25519 +} +``` + +## v1 Scope + +This package exposes only: + +- `CreateWallet`, `ListWallets` (wallet operations) +- `SignMessage`, `SignTx` (signing operations) +- `WalletInfo`, `AccountInfo`, `SignResult`, `Error` types +- `IndexNone` sentinel constant +- `IsWalletNotFound`, `IsSignerError` helpers + +**Explicitly deferred to v2**: `ImportWallet`, `DeleteWallet`, `ExportWallet`, `RenameWallet`, `GetWallet`, `SignTypedData`, `SignAndSend`, policy management, API key management. + +## Examples + +A runnable demo is provided at `examples/demo.go`. Build the Rust library first, then run from the `bindings/go` directory: + +```bash +# From the bindings/go directory +export CGO_ENABLED=1 +export CGO_LDFLAGS="-L$(pwd)/target/release -lows_go" +go run ./examples/demo.go +``` + +## Troubleshooting + +**"library not found" during `go build`** + +Set `CGO_LDFLAGS` to the path containing `libows_go.so` (Linux), `libows_go.dylib` (macOS), or `ows_go.dll`/`ows_go.dll.lib` (Windows): + +```bash +export CGO_LDFLAGS="-L$(pwd)/target/release -lows_go" +``` + +On Windows, also add `target/release` to `PATH` before `go test` or `go run` so the DLL can be loaded at runtime. + +**"ows-go library not found" on macOS after update** + +Clear the Cargo build cache and rebuild: + +```bash +cargo build --release -p ows-go +``` + +**Tests fail with "wallet not found"** + +Tests use isolated temporary vaults and clean up after themselves. If a test process is killed, stale temp directories may remain — these are safe to delete manually. + +**Panic or segfault in Rust FFI** + +Ensure the Rust cdylib and Go package are rebuilt from the same commit. Mixing a stale `.so`/`.dylib`/`.dll` with a newer Go package can cause ABI mismatches. + +## Memory Model + +All strings passed to Rust are copied; Rust allocations are freed by the Go package internally. Callers do not need to manage FFI memory. + +## License + +MIT diff --git a/bindings/go/build.rs b/bindings/go/build.rs new file mode 100644 index 00000000..7c8ca314 --- /dev/null +++ b/bindings/go/build.rs @@ -0,0 +1,5 @@ +// Build script for ows-go. +// +// The crate builds as a cdylib automatically via Cargo.toml. +// No external build steps needed. +fn main() {} diff --git a/bindings/go/examples/demo.go b/bindings/go/examples/demo.go new file mode 100644 index 00000000..34fad27a --- /dev/null +++ b/bindings/go/examples/demo.go @@ -0,0 +1,114 @@ +//go:build ignore + +// Demo program for the OWS Go binding. +// +// Build the Rust FFI library first: +// +// cargo build --release -p ows-go +// +// Then run with CGO_LDFLAGS set: +// +// # Linux/macOS +// export CGO_ENABLED=1 +// export CGO_LDFLAGS="-L$(pwd)/target/release -lows_go" +// go run ./examples/demo.go +// +// # Windows (PowerShell) +// $release = (Resolve-Path .\target\release).Path +// $env:CGO_ENABLED="1" +// $env:CGO_LDFLAGS="-L$($release -replace '\\','/') -lows_go" +// $env:PATH="$release;$env:PATH" +// go run .\examples\demo.go +// +// The demo creates a wallet in a temporary vault, signs a message and a +// transaction, then prints the results. No data is written to the default vault. + +package main + +import ( + "fmt" + "os" + + ows "github.com/open-wallet-standard/core/bindings/go/ows" +) + +func main() { + // Use a temporary vault so this demo never touches the user's real vault. + vault, err := os.MkdirTemp("", "ows-demo-*") + if err != nil { + fmt.Fprintf(os.Stderr, "create temp vault: %v\n", err) + os.Exit(1) + } + defer os.RemoveAll(vault) + + // ── 1. Create a wallet ──────────────────────────────────────────────────── + fmt.Println("=== CreateWallet ===") + wallet, err := ows.CreateWallet("demo-wallet", "hunter2", 12, vault) + if err != nil { + fmt.Fprintf(os.Stderr, "CreateWallet failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("Created wallet %q (ID: %s)\n", wallet.Name, wallet.ID) + fmt.Println("Accounts:") + for _, acct := range wallet.Accounts { + fmt.Printf(" chain=%s address=%s derivation=%s\n", + acct.ChainID, acct.Address, acct.DerivationPath) + } + + // ── 2. List wallets ─────────────────────────────────────────────────────── + fmt.Println("\n=== ListWallets ===") + wallets, err := ows.ListWallets(vault) + if err != nil { + fmt.Fprintf(os.Stderr, "ListWallets failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("Found %d wallet(s)\n", len(wallets)) + for _, w := range wallets { + fmt.Printf(" - %s (ID: %s)\n", w.Name, w.ID) + } + + // ── 3. Sign a message (EVM) ─────────────────────────────────────────────── + fmt.Println("\n=== SignMessage (EVM) ===") + sr, err := ows.SignMessage( + wallet.Name, // wallet name + "evm", // chain + "hello world", // UTF-8 message + "hunter2", // passphrase + "", // encoding (auto-detect) + ows.IndexNone, // use account index 0 + vault, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "SignMessage failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("Signature (hex): %s\n", sr.Signature) + if sr.RecoveryID != nil { + fmt.Printf("Recovery ID: %d\n", *sr.RecoveryID) + } else { + fmt.Println("Recovery ID: (Ed25519)") + } + + // ── 4. Sign a raw transaction (EVM) ────────────────────────────────────── + fmt.Println("\n=== SignTx (EVM) ===") + // Raw EVM transaction hex (dummy payload — not a real transaction). + txHex := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + srTx, err := ows.SignTx( + wallet.Name, // wallet name + "evm", // chain + txHex, // hex-encoded transaction + "hunter2", // passphrase + ows.IndexNone, // use account index 0 + vault, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "SignTx failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("Transaction signature (hex): %s\n", srTx.Signature) + if srTx.RecoveryID != nil { + fmt.Printf("Recovery ID: %d\n", *srTx.RecoveryID) + } + + fmt.Println("\n=== Done ===") +} diff --git a/bindings/go/go.mod b/bindings/go/go.mod new file mode 100644 index 00000000..2eeb1af7 --- /dev/null +++ b/bindings/go/go.mod @@ -0,0 +1,11 @@ +module github.com/open-wallet-standard/core/bindings/go + +go 1.21 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/bindings/go/go.sum b/bindings/go/go.sum new file mode 100644 index 00000000..60ce688a --- /dev/null +++ b/bindings/go/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bindings/go/ows/api.go b/bindings/go/ows/api.go new file mode 100644 index 00000000..34370a2f --- /dev/null +++ b/bindings/go/ows/api.go @@ -0,0 +1,245 @@ +package ows + +/* +#cgo LDFLAGS: -lows_go + +#include +#include + +// Rust FFI — all have C linkage via #[no_mangle] pub extern "C" +extern char* ows_go_create_wallet(const char* name, const char* passphrase, uint32_t words, const char* vault_path); +extern char* ows_go_list_wallets(const char* vault_path); +extern char* ows_go_sign_message(const char* wallet, const char* chain, const char* message, const char* passphrase, const char* encoding, uint32_t index, const char* vault_path); +extern char* ows_go_sign_transaction(const char* wallet, const char* chain, const char* tx_hex, const char* passphrase, uint32_t index, const char* vault_path); +extern void ows_go_free_string(char* s); +extern int32_t ows_go_get_last_error_code(void); +extern const char* ows_go_get_last_error(void); +*/ +import "C" +import ( + "encoding/json" + "errors" + "fmt" + "unsafe" +) + +// IndexNone is the sentinel for "use default account index (0)". +const IndexNone = ^uint32(0) + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +// Error codes matching the Rust library. +const ( + CodeOK = 0 + CodeWalletNotFound = 1 + CodeWalletAmbiguous = 2 + CodeWalletExists = 3 + CodeInvalidInput = 4 + CodeBroadcastFailed = 5 + CodeCrypto = 6 + CodeSigner = 7 + CodeMnemonic = 8 + CodeHD = 9 + CodeCore = 10 + CodeIO = 11 + CodeJSON = 12 + CodeUnknown = 99 +) + +// Error is returned by all package functions on failure. +type Error struct { + Code int + Message string +} + +func (e *Error) Error() string { + if e.Message != "" { + return fmt.Sprintf("ows: [%d] %s", e.Code, e.Message) + } + return fmt.Sprintf("ows: error code %d", e.Code) +} + +// Is reports whether err is the same as e (code+message match). +func (e *Error) Is(err error) bool { + var o *Error + if !errors.As(err, &o) { + return false + } + return e.Code == o.Code && e.Message == o.Message +} + +// IsWalletNotFound reports whether err indicates a wallet was not found. +func IsWalletNotFound(err error) bool { + var e *Error + return errors.As(err, &e) && e.Code == CodeWalletNotFound +} + +// IsSignerError reports whether err indicates a signing operation failed +// (e.g. invalid payload, unsupported chain, key not found). +func IsSignerError(err error) bool { + var e *Error + return errors.As(err, &e) && e.Code == CodeSigner +} + +// --------------------------------------------------------------------------- +// Internal FFI helpers +// --------------------------------------------------------------------------- + +// lastError reads the thread-local error state set by the last Rust call. +func lastError() *Error { + code := int(C.ows_go_get_last_error_code()) + if code == CodeOK { + return nil + } + return &Error{Code: code, Message: C.GoString(C.ows_go_get_last_error())} +} + +// goFree frees a C string allocated by Go via C.CString (use stdlib free). +func goFree(c *C.char) { + C.free(unsafe.Pointer(c)) +} + +// rustFree frees a C string allocated by Rust (use ows_go_free_string). +func rustFree(c *C.char) { + if c != nil { + C.ows_go_free_string(c) + } +} + +// --------------------------------------------------------------------------- +// Wallet operations (v1) +// --------------------------------------------------------------------------- + +// CreateWallet creates a new wallet with derived addresses for all supported chains. +// Pass "" for vaultPath to use the default vault (~/.ows). +// +// Example: +// +// wi, err := ows.CreateWallet("my-wallet", "hunter2", 12, "") +func CreateWallet(name, passphrase string, words uint, vaultPath string) (*WalletInfo, error) { + if name == "" { + return nil, &Error{Code: CodeInvalidInput, Message: "wallet name cannot be empty"} + } + + cname := C.CString(name) + cpass := C.CString(passphrase) + cvault := C.CString(vaultPath) + defer goFree(cname) + defer goFree(cpass) + defer goFree(cvault) + + res := C.ows_go_create_wallet(cname, cpass, C.uint(words), cvault) + if res == nil { + return nil, lastError() + } + defer rustFree(res) + + var wi WalletInfo + if err := json.Unmarshal([]byte(C.GoString(res)), &wi); err != nil { + return nil, fmt.Errorf("ows: parse WalletInfo: %w", err) + } + return &wi, nil +} + +// ListWallets returns all wallets in the vault. +// Pass "" for vaultPath to use the default vault (~/.ows). +// +// Example: +// +// wallets, err := ows.ListWallets("") +func ListWallets(vaultPath string) ([]*WalletInfo, error) { + cvault := C.CString(vaultPath) + defer goFree(cvault) + + res := C.ows_go_list_wallets(cvault) + if res == nil { + return nil, lastError() + } + defer rustFree(res) + + var wallets []*WalletInfo + if err := json.Unmarshal([]byte(C.GoString(res)), &wallets); err != nil { + return nil, fmt.Errorf("ows: parse WalletInfo slice: %w", err) + } + return wallets, nil +} + +// --------------------------------------------------------------------------- +// Signing operations (v1) +// --------------------------------------------------------------------------- + +// SignMessage signs a UTF-8 message on behalf of a wallet. +// chain is the chain identifier (e.g. "evm", "solana", "bitcoin"). +// encoding may be "" for auto-detect. Use IndexNone for index to default to 0. +// Pass "" for vaultPath to use the default vault (~/.ows). +// +// Example: +// +// sig, err := ows.SignMessage("my-wallet", "evm", "hello world", "hunter2", "", ows.IndexNone, "") +func SignMessage(wallet, chain, message, passphrase, encoding string, index uint32, vaultPath string) (*SignResult, error) { + if encoding == "" { + encoding = "utf8" + } + + cwallet := C.CString(wallet) + cchain := C.CString(chain) + cmessage := C.CString(message) + cpass := C.CString(passphrase) + cenc := C.CString(encoding) + cvault := C.CString(vaultPath) + defer func() { + goFree(cwallet) + goFree(cchain) + goFree(cmessage) + goFree(cpass) + goFree(cenc) + goFree(cvault) + }() + + res := C.ows_go_sign_message(cwallet, cchain, cmessage, cpass, cenc, C.uint(index), cvault) + if res == nil { + return nil, lastError() + } + defer rustFree(res) + + var sr SignResult + if err := json.Unmarshal([]byte(C.GoString(res)), &sr); err != nil { + return nil, fmt.Errorf("ows: parse SignResult: %w", err) + } + return &sr, nil +} + +// SignTx signs a raw transaction (hex-encoded bytes). +// Use IndexNone for index to default to 0. +// +// Example: +// +// sig, err := ows.SignTx("my-wallet", "evm", "deadbeef...", "hunter2", ows.IndexNone, "") +func SignTx(wallet, chain, txHex, passphrase string, index uint32, vaultPath string) (*SignResult, error) { + cwallet := C.CString(wallet) + cchain := C.CString(chain) + ctxhex := C.CString(txHex) + cpass := C.CString(passphrase) + cvault := C.CString(vaultPath) + defer func() { + goFree(cwallet) + goFree(cchain) + goFree(ctxhex) + goFree(cpass) + goFree(cvault) + }() + + res := C.ows_go_sign_transaction(cwallet, cchain, ctxhex, cpass, C.uint(index), cvault) + if res == nil { + return nil, lastError() + } + defer rustFree(res) + + var sr SignResult + if err := json.Unmarshal([]byte(C.GoString(res)), &sr); err != nil { + return nil, fmt.Errorf("ows: parse SignResult: %w", err) + } + return &sr, nil +} diff --git a/bindings/go/ows/types.go b/bindings/go/ows/types.go new file mode 100644 index 00000000..1cfc686c --- /dev/null +++ b/bindings/go/ows/types.go @@ -0,0 +1,22 @@ +package ows + +// AccountInfo represents a single account within a wallet (one per chain family). +type AccountInfo struct { + ChainID string `json:"chain_id"` + Address string `json:"address"` + DerivationPath string `json:"derivation_path"` +} + +// WalletInfo represents a wallet returned by CreateWallet or ListWallets. +type WalletInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Accounts []*AccountInfo `json:"accounts"` + CreatedAt string `json:"created_at"` +} + +// SignResult is returned by SignMessage and SignTx. +type SignResult struct { + Signature string `json:"signature"` + RecoveryID *uint8 `json:"recovery_id"` +} diff --git a/bindings/go/ows/wallet.go b/bindings/go/ows/wallet.go new file mode 100644 index 00000000..01032018 --- /dev/null +++ b/bindings/go/ows/wallet.go @@ -0,0 +1,3 @@ +package ows + +// This file intentionally left empty. Implementation lives in api.go. diff --git a/bindings/go/ows/wallet_test.go b/bindings/go/ows/wallet_test.go new file mode 100644 index 00000000..d7c341fa --- /dev/null +++ b/bindings/go/ows/wallet_test.go @@ -0,0 +1,642 @@ +package ows + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// tempVault creates a new temporary directory and returns its path. +// The caller is responsible for removing it after the test. +func tempVault(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "ows-test-*") + require.NoError(t, err) + return dir +} + +func cleanupVault(t *testing.T, path string) { + t.Helper() + if path != "" { + os.RemoveAll(path) + } +} + +// --------------------------------------------------------------------------- +// CreateWallet tests +// --------------------------------------------------------------------------- + +func TestCreateWallet_OK(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("test-wallet", "hunter2", 12, vault) + require.NoError(t, err) + require.NotNil(t, wi) + + assert.NotEmpty(t, wi.ID) + assert.Equal(t, "test-wallet", wi.Name) + assert.NotEmpty(t, wi.CreatedAt) + assert.NotEmpty(t, wi.Accounts, "expected at least one account") +} + +// TestCreateWallet_DefaultWords verifies that words=0 uses the default (12). +func TestCreateWallet_DefaultWords(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("default-words", "", 0, vault) + require.NoError(t, err) + require.NotNil(t, wi) + assert.Equal(t, "default-words", wi.Name) +} + +func TestCreateWallet_EmptyName(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + _, err := CreateWallet("", "", 12, vault) + require.Error(t, err) + var e *Error + require.ErrorAs(t, err, &e) + assert.Equal(t, CodeInvalidInput, e.Code) +} + +// TestCreateWallet_DuplicateName verifies that creating a second wallet with +// the same name returns a WalletExists error. +func TestCreateWallet_DuplicateName(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + _, err := CreateWallet("dup-wallet", "", 12, vault) + require.NoError(t, err) + + _, err = CreateWallet("dup-wallet", "", 12, vault) + require.Error(t, err) + var e *Error + require.ErrorAs(t, err, &e) + assert.Equal(t, CodeWalletExists, e.Code) +} + +func TestCreateWallet_InvalidWordCount(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + _, err := CreateWallet("bad-words", "", 13, vault) // 13 is not valid + require.Error(t, err) +} + +// TestCreateWallet_EmptyPassphrase verifies that an empty passphrase is accepted. +func TestCreateWallet_EmptyPassphrase(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("no-pass", "", 12, vault) + require.NoError(t, err) + assert.Equal(t, "no-pass", wi.Name) +} + +// --------------------------------------------------------------------------- +// ListWallets tests +// --------------------------------------------------------------------------- + +func TestListWallets_Empty(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wallets, err := ListWallets(vault) + require.NoError(t, err) + assert.Empty(t, wallets) +} + +func TestListWallets_OneWallet(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + created, err := CreateWallet("list-test", "", 12, vault) + require.NoError(t, err) + + wallets, err := ListWallets(vault) + require.NoError(t, err) + require.Len(t, wallets, 1) + assert.Equal(t, created.ID, wallets[0].ID) + assert.Equal(t, "list-test", wallets[0].Name) +} + +func TestListWallets_MultipleWallets(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + names := []string{"wallet-a", "wallet-b", "wallet-c"} + for _, name := range names { + _, err := CreateWallet(name, "", 12, vault) + require.NoError(t, err, "failed to create %s", name) + } + + wallets, err := ListWallets(vault) + require.NoError(t, err) + assert.Len(t, wallets, 3, "expected 3 wallets") + + // Verify each wallet has at least one account with a non-empty address. + for _, w := range wallets { + require.NotEmpty(t, w.Accounts, "wallet %s has no accounts", w.Name) + for _, acct := range w.Accounts { + assert.NotEmpty(t, acct.ChainID) + assert.NotEmpty(t, acct.Address) + } + } +} + +func TestListWallets_Stable(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + _, err := CreateWallet("stable-wallet", "", 12, vault) + require.NoError(t, err) + + // Call ListWallets twice; order and content should be identical. + first, err := ListWallets(vault) + require.NoError(t, err) + + second, err := ListWallets(vault) + require.NoError(t, err) + + assert.Equal(t, len(first), len(second)) + for i := range first { + assert.Equal(t, first[i].ID, second[i].ID) + assert.Equal(t, first[i].Name, second[i].Name) + } +} + +func TestListWallets_PreservesAccountData(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + created, err := CreateWallet("account-check", "", 12, vault) + require.NoError(t, err) + + wallets, err := ListWallets(vault) + require.NoError(t, err) + require.Len(t, wallets, 1) + + got := wallets[0] + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, created.Name, got.Name) + assert.Equal(t, created.CreatedAt, got.CreatedAt) + require.Len(t, got.Accounts, len(created.Accounts)) + for i, acct := range got.Accounts { + assert.Equal(t, created.Accounts[i].ChainID, acct.ChainID) + assert.Equal(t, created.Accounts[i].Address, acct.Address) + assert.Equal(t, created.Accounts[i].DerivationPath, acct.DerivationPath) + } +} + +// TestListWallets_NonexistentVault verifies that a non-existent vault directory +// returns an empty list (not an error), matching ows-lib behavior. +func TestListWallets_NonexistentVault(t *testing.T) { + nonexistent := filepath.Join(tempVault(t), "does-not-exist") + // Cleanup only the parent; the child never existed. + defer os.RemoveAll(filepath.Dir(nonexistent)) + + wallets, err := ListWallets(nonexistent) + // Depending on implementation this may error or return empty. + // The important thing is it does not panic. + if err == nil { + assert.Empty(t, wallets) + } +} + +// --------------------------------------------------------------------------- +// Error type tests +// --------------------------------------------------------------------------- + +func TestError_Error(t *testing.T) { + e := &Error{Code: CodeWalletNotFound, Message: "wallet not found: 'foo'"} + assert.Equal(t, "ows: [1] wallet not found: 'foo'", e.Error()) + + e2 := &Error{Code: CodeUnknown, Message: ""} + assert.Equal(t, "ows: error code 99", e2.Error()) +} + +func TestError_Is(t *testing.T) { + e1 := &Error{Code: 1, Message: "a"} + e2 := &Error{Code: 1, Message: "a"} + e3 := &Error{Code: 1, Message: "b"} + e4 := &Error{Code: 2, Message: "a"} + + assert.True(t, e1.Is(e2)) + assert.False(t, e1.Is(e3)) + assert.False(t, e1.Is(e4)) +} + +func TestIsWalletNotFound(t *testing.T) { + errNotFound := &Error{Code: CodeWalletNotFound, Message: "wallet not found"} + errOther := &Error{Code: CodeInvalidInput, Message: "bad input"} + + assert.True(t, IsWalletNotFound(errNotFound)) + assert.False(t, IsWalletNotFound(errOther)) + assert.False(t, IsWalletNotFound(nil)) +} + +// --------------------------------------------------------------------------- +// Constants and types compile check +// --------------------------------------------------------------------------- + +func TestIndexNoneConstant(t *testing.T) { + assert.Equal(t, uint32(0xFFFFFFFF), IndexNone) +} + +func TestWalletInfoFields(t *testing.T) { + wi := &WalletInfo{ + ID: "id-123", + Name: "my-wallet", + CreatedAt: "1234567890", + Accounts: []*AccountInfo{ + {ChainID: "evm", Address: "0xABC", DerivationPath: "m/44'/60'/0'/0/0"}, + }, + } + assert.Equal(t, "id-123", wi.ID) + assert.Equal(t, "my-wallet", wi.Name) + assert.Equal(t, "0xABC", wi.Accounts[0].Address) +} + +func TestSignResultFields(t *testing.T) { + rid := uint8(27) + sr := &SignResult{Signature: "0xdeadbeef", RecoveryID: &rid} + assert.Equal(t, "0xdeadbeef", sr.Signature) + assert.Equal(t, uint8(27), *sr.RecoveryID) + + srEd := &SignResult{Signature: "sig123", RecoveryID: nil} + assert.Nil(t, srEd.RecoveryID) +} + +// --------------------------------------------------------------------------- +// SignMessage tests +// --------------------------------------------------------------------------- + +func TestSignMessage_EvmSuccess(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sign-msg-evm", "hunter2", 12, vault) + require.NoError(t, err) + require.NotEmpty(t, wi.Accounts) + + sr, err := SignMessage(wi.Name, "evm", "hello world", "hunter2", "", IndexNone, vault) + require.NoError(t, err) + require.NotNil(t, sr) + assert.NotEmpty(t, sr.Signature, "expected non-empty signature") + // EVM uses secp256k1 so recovery_id is present + assert.NotNil(t, sr.RecoveryID, "expected recovery_id for secp256k1 chains") +} + +func TestSignMessage_SolanaSuccess(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sign-msg-sol", "hunter2", 12, vault) + require.NoError(t, err) + + // Solana uses Ed25519 — no recovery_id + sr, err := SignMessage(wi.Name, "solana", "hello solana", "hunter2", "", IndexNone, vault) + require.NoError(t, err) + require.NotNil(t, sr) + assert.NotEmpty(t, sr.Signature) + assert.Nil(t, sr.RecoveryID, "Ed25519 chains should not have recovery_id") +} + +func TestSignMessage_EmptyPassphrase(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sign-no-pass", "", 12, vault) + require.NoError(t, err) + + sr, err := SignMessage(wi.Name, "evm", "no passphrase", "", "", IndexNone, vault) + require.NoError(t, err) + assert.NotEmpty(t, sr.Signature) +} + +func TestSignMessage_MissingWallet(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + _, err := SignMessage("nonexistent-wallet", "evm", "hello", "", "", IndexNone, vault) + require.Error(t, err) + assert.True(t, IsWalletNotFound(err), "expected wallet-not-found error, got: %v", err) +} + +func TestSignMessage_InvalidChain(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sign-bad-chain", "hunter2", 12, vault) + require.NoError(t, err) + + _, err = SignMessage(wi.Name, "not-a-chain", "hello", "hunter2", "", IndexNone, vault) + require.Error(t, err) + var e *Error + require.ErrorAs(t, err, &e) + assert.Equal(t, CodeInvalidInput, e.Code) +} + +func TestSignMessage_WrongPassphrase(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sign-wrong-pass", "correct", 12, vault) + require.NoError(t, err) + + _, err = SignMessage(wi.Name, "evm", "hello", "wrong-passphrase", "", IndexNone, vault) + require.Error(t, err) + var e *Error + require.ErrorAs(t, err, &e) + assert.Equal(t, CodeUnknown, e.Code) +} + +func TestSignMessage_EmptyMessage(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sign-empty-msg", "hunter2", 12, vault) + require.NoError(t, err) + + // Empty message should still produce a valid signature (some chains may reject it downstream). + sr, err := SignMessage(wi.Name, "evm", "", "hunter2", "", IndexNone, vault) + require.NoError(t, err) + assert.NotEmpty(t, sr.Signature) +} + +// --------------------------------------------------------------------------- +// SignTx tests +// --------------------------------------------------------------------------- + +func TestSignTx_EvmSuccess(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sign-tx-evm", "hunter2", 12, vault) + require.NoError(t, err) + + // Raw EVM transaction hex (dummy payload — not a real transaction). + // ows-lib treats this as opaque signable bytes. + txHex := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + sr, err := SignTx(wi.Name, "evm", txHex, "hunter2", IndexNone, vault) + require.NoError(t, err) + require.NotNil(t, sr) + assert.NotEmpty(t, sr.Signature, "expected non-empty signature") + assert.NotNil(t, sr.RecoveryID, "expected recovery_id for secp256k1") +} + +func TestSignTx_BitcoinSuccess(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sign-tx-btc", "hunter2", 12, vault) + require.NoError(t, err) + + // Dummy Bitcoin transaction hex. + txHex := "0200000001" + sr, err := SignTx(wi.Name, "bitcoin", txHex, "hunter2", IndexNone, vault) + require.NoError(t, err) + assert.NotEmpty(t, sr.Signature) + assert.NotNil(t, sr.RecoveryID, "secp256k1 chains should have recovery_id") +} + +func TestSignTx_MissingWallet(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + _, err := SignTx("nonexistent", "evm", "deadbeef", "", IndexNone, vault) + require.Error(t, err) + assert.True(t, IsWalletNotFound(err), "expected wallet-not-found error, got: %v", err) +} + +func TestSignTx_InvalidChain(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("signtx-bad-chain", "hunter2", 12, vault) + require.NoError(t, err) + + _, err = SignTx(wi.Name, "not-a-chain", "deadbeef", "hunter2", IndexNone, vault) + require.Error(t, err) + var e *Error + require.ErrorAs(t, err, &e) + assert.Equal(t, CodeInvalidInput, e.Code) +} + +func TestSignTx_MalformedHex(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("signtx-bad-hex", "hunter2", 12, vault) + require.NoError(t, err) + + // Not valid hex (contains 'g'). + _, err = SignTx(wi.Name, "evm", "notg00dh3x", "hunter2", IndexNone, vault) + require.Error(t, err) + var e *Error + require.ErrorAs(t, err, &e) + assert.Equal(t, CodeInvalidInput, e.Code) +} + +func TestSignTx_EmptyHex(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("signtx-empty-hex", "hunter2", 12, vault) + require.NoError(t, err) + + // The Rust library treats the raw transaction payload opaquely, so even an + // empty payload still produces a deterministic signature. + sr, err := SignTx(wi.Name, "evm", "", "hunter2", IndexNone, vault) + require.NoError(t, err) + assert.NotEmpty(t, sr.Signature) +} + +// --------------------------------------------------------------------------- +// IsSignerError tests +// --------------------------------------------------------------------------- + +func TestIsSignerError(t *testing.T) { + errSigner := &Error{Code: CodeSigner, Message: "signer error"} + errOther := &Error{Code: CodeInvalidInput, Message: "bad input"} + + assert.True(t, IsSignerError(errSigner)) + assert.False(t, IsSignerError(errOther)) + assert.False(t, IsSignerError(nil)) +} + +// --------------------------------------------------------------------------- +// Parity / signature format tests +// --------------------------------------------------------------------------- + +func TestSignMessage_SignatureIsValidHex(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("sig-hex", "hunter2", 12, vault) + require.NoError(t, err) + + for _, chain := range []string{"evm", "bitcoin", "cosmos", "tron", "filecoin"} { + sr, err := SignMessage(wi.Name, chain, "test message", "hunter2", "", IndexNone, vault) + require.NoError(t, err, "signing failed for chain %s", chain) + assert.NotEmpty(t, sr.Signature, "empty signature for %s", chain) + // Signature must be valid hex (no 0x prefix expected from lib). + for _, c := range sr.Signature { + assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'), + "non-hex char %c in signature for %s", c, chain) + } + } +} + +func TestSignMessage_RecoveryIDRange(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("rec-id", "hunter2", 12, vault) + require.NoError(t, err) + + // secp256k1 chains: evm, bitcoin, cosmos, tron, spark, filecoin + secpChains := []string{"evm", "bitcoin", "cosmos", "tron", "spark", "filecoin"} + for _, chain := range secpChains { + sr, err := SignMessage(wi.Name, chain, "hello", "hunter2", "", IndexNone, vault) + require.NoError(t, err, "signing failed for %s", chain) + require.NotNil(t, sr.RecoveryID, "recovery_id must be present for secp256k1 chain %s", chain) + assert.Contains(t, []uint8{0, 1, 27, 28}, *sr.RecoveryID, "unexpected recovery_id for %s", chain) + } +} + +func TestSignMessage_Ed25519HasNilRecoveryID(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("ed25519-nil-rec", "hunter2", 12, vault) + require.NoError(t, err) + + // All known Ed25519 chains should return nil recovery ID. + edChains := []string{"solana", "ton", "sui"} + for _, chain := range edChains { + sr, err := SignMessage(wi.Name, chain, "hello", "hunter2", "", IndexNone, vault) + require.NoError(t, err, "signing failed for %s", chain) + assert.Nil(t, sr.RecoveryID, "recovery_id must be nil for Ed25519 chain %s", chain) + } +} + +func TestSignTx_SignatureIsValidHex(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("signtx-hex", "hunter2", 12, vault) + require.NoError(t, err) + + txHex := "f86c088501dcd6500082520894675300000000000000000000000077702a8dbe5f2a03021856" + sr, err := SignTx(wi.Name, "evm", txHex, "hunter2", IndexNone, vault) + require.NoError(t, err) + assert.NotEmpty(t, sr.Signature) + for _, c := range sr.Signature { + assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'), + "non-hex char %c in tx signature", c) + } +} + +func TestSignTx_RecoveryIDRange(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("signtx-rec", "hunter2", 12, vault) + require.NoError(t, err) + + txHex := "f86c088501dcd6500082520894675300000000000000000000000077702a8dbe5f2a03021856" + sr, err := SignTx(wi.Name, "evm", txHex, "hunter2", IndexNone, vault) + require.NoError(t, err) + require.NotNil(t, sr.RecoveryID) + assert.LessOrEqual(t, *sr.RecoveryID, uint8(3)) +} + +func TestCreateWallet_DerivationPathFormat(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("deriv-path", "", 12, vault) + require.NoError(t, err) + require.NotEmpty(t, wi.Accounts) + + for _, acct := range wi.Accounts { + assert.NotEmpty(t, acct.DerivationPath, "chain %s has empty derivation path", acct.ChainID) + // BIP-44 derivation path pattern: m/44'/'/'// + // or similar. Must start with "m/". + assert.True(t, len(acct.DerivationPath) >= 4 && acct.DerivationPath[:2] == "m/", + "derivation path %s doesn't start with 'm/'", acct.DerivationPath) + } +} + +func TestCreateWallet_CosmosAndTronAccounts(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + wi, err := CreateWallet("multi-chain", "", 12, vault) + require.NoError(t, err) + require.NotEmpty(t, wi.Accounts) + + chains := make(map[string]bool) + for _, acct := range wi.Accounts { + chains[acct.ChainID] = true + assert.NotEmpty(t, acct.Address, "chain %s has empty address", acct.ChainID) + } + // A default wallet should expose multiple chain accounts. + assert.GreaterOrEqual(t, len(chains), 2, "expected at least 2 distinct chain accounts") +} + +// TestCreateWallet_RoundTrip verifies that creating and re-listing a wallet +// preserves every field byte-for-byte, including CreatedAt. +func TestCreateWallet_RoundTrip(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + created, err := CreateWallet("roundtrip", "", 12, vault) + require.NoError(t, err) + + wallets, err := ListWallets(vault) + require.NoError(t, err) + require.Len(t, wallets, 1) + + got := wallets[0] + assert.Equal(t, created.ID, got.ID, "ID must match") + assert.Equal(t, created.Name, got.Name, "Name must match") + assert.Equal(t, created.CreatedAt, got.CreatedAt, "CreatedAt must match") + require.Len(t, got.Accounts, len(created.Accounts), "Account count must match") + for i := range got.Accounts { + assert.Equal(t, created.Accounts[i].ChainID, got.Accounts[i].ChainID, "ChainID must match") + assert.Equal(t, created.Accounts[i].Address, got.Accounts[i].Address, "Address must match") + assert.Equal(t, created.Accounts[i].DerivationPath, got.Accounts[i].DerivationPath, "DerivationPath must match") + } +} + +func TestListWallets_OrderStable(t *testing.T) { + vault := tempVault(t) + defer cleanupVault(t, vault) + + for i := 0; i < 5; i++ { + _, err := CreateWallet(fmt.Sprintf("ordered-wallet-%d", i), "", 12, vault) + require.NoError(t, err) + } + + first, err := ListWallets(vault) + require.NoError(t, err) + + // Call 3 more times — order must be identical. + for n := 0; n < 3; n++ { + wallets, err := ListWallets(vault) + require.NoError(t, err) + require.Len(t, wallets, len(first)) + for i := range wallets { + assert.Equal(t, first[i].ID, wallets[i].ID, "order changed on call %d", n+2) + } + } +} diff --git a/bindings/go/src/error.h b/bindings/go/src/error.h new file mode 100644 index 00000000..b212ee86 --- /dev/null +++ b/bindings/go/src/error.h @@ -0,0 +1,23 @@ +#ifndef OWS_GO_ERROR_H +#define OWS_GO_ERROR_H + +#include + +typedef int32_t ows_go_error_code_t; + +#define OWS_GO_OK 0 +#define OWS_GO_ERR_WALLET_NOT_FOUND 1 +#define OWS_GO_ERR_WALLET_AMBIGUOUS 2 +#define OWS_GO_ERR_WALLET_EXISTS 3 +#define OWS_GO_ERR_INVALID_INPUT 4 +#define OWS_GO_ERR_BROADCAST_FAILED 5 +#define OWS_GO_ERR_CRYPTO 6 +#define OWS_GO_ERR_SIGNER 7 +#define OWS_GO_ERR_MNEMONIC 8 +#define OWS_GO_ERR_HD 9 +#define OWS_GO_ERR_CORE 10 +#define OWS_GO_ERR_IO 11 +#define OWS_GO_ERR_JSON 12 +#define OWS_GO_ERR_UNKNOWN 99 + +#endif // OWS_GO_ERROR_H diff --git a/bindings/go/src/error.rs b/bindings/go/src/error.rs new file mode 100644 index 00000000..8e1ea56b --- /dev/null +++ b/bindings/go/src/error.rs @@ -0,0 +1,108 @@ +//! Error codes for the Go FFI layer. + +/// Error codes returned by `ows_go_get_last_error_code()`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum OwsGoError { + Ok = 0, + WalletNotFound = 1, + WalletAmbiguous = 2, + WalletExists = 3, + InvalidInput = 4, + BroadcastFailed = 5, + Crypto = 6, + Signer = 7, + Mnemonic = 8, + Hd = 9, + Core = 10, + Io = 11, + Json = 12, + Unknown = 99, +} + +impl OwsGoError { + /// Classify an error message string into an error code. + #[must_use] + pub fn from_error_msg(msg: &str) -> Self { + if msg.contains("wallet not found") { + Self::WalletNotFound + } else if msg.contains("ambiguous wallet") { + Self::WalletAmbiguous + } else if msg.contains("wallet name already exists") { + Self::WalletExists + } else if msg.contains("invalid input") { + Self::InvalidInput + } else if msg.contains("broadcast failed") { + Self::BroadcastFailed + } else if msg.contains("crypto") { + Self::Crypto + } else if msg.contains("signer error") { + Self::Signer + } else if msg.contains("mnemonic") { + Self::Mnemonic + } else if msg.contains(" HD ") || msg.contains("HD derivation") { + Self::Hd + } else if msg.contains("core error") || msg.contains("OwsError") { + Self::Core + } else if msg.contains("I/O") || msg.contains("io error") { + Self::Io + } else if msg.contains("JSON") { + Self::Json + } else { + Self::Unknown + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wallet_not_found() { + assert_eq!( + OwsGoError::from_error_msg("wallet not found: 'my-wallet'"), + OwsGoError::WalletNotFound + ); + } + + #[test] + fn test_wallet_ambiguous() { + assert_eq!( + OwsGoError::from_error_msg("ambiguous wallet name 'test' matches 2 wallets"), + OwsGoError::WalletAmbiguous + ); + } + + #[test] + fn test_wallet_exists() { + assert_eq!( + OwsGoError::from_error_msg("wallet name already exists: 'my-wallet'"), + OwsGoError::WalletExists + ); + } + + #[test] + fn test_invalid_input() { + assert_eq!( + OwsGoError::from_error_msg("invalid input: empty name"), + OwsGoError::InvalidInput + ); + } + + #[test] + fn test_io_error() { + assert_eq!( + OwsGoError::from_error_msg("I/O error: No such file"), + OwsGoError::Io + ); + } + + #[test] + fn test_unknown() { + assert_eq!( + OwsGoError::from_error_msg("some totally unexpected error"), + OwsGoError::Unknown + ); + } +} diff --git a/bindings/go/src/lib.rs b/bindings/go/src/lib.rs new file mode 100644 index 00000000..ecc0d162 --- /dev/null +++ b/bindings/go/src/lib.rs @@ -0,0 +1,368 @@ +//! Go FFI bindings for the Open Wallet Standard (OWS) +//! +//! ## ABI Overview +//! +//! This crate exposes a minimal C-compatible FFI surface for Go via cgo. +//! All functions use `#[no_mangle]` and `extern "C"` ABI. +//! +//! ## Memory Ownership Rules +//! +//! - **Input strings**: Borrowed from caller; Rust does not free them. +//! - **Output strings**: Heap-allocated by Rust via CString::into_raw(); +//! ownership is transferred to caller. Caller MUST call +//! `ows_go_free_string()` to release. +//! - **Error state**: Thread-local cell; overwritten on each FFI call. +//! Retrieve via `ows_go_get_last_error()` / `ows_go_get_last_error_code()`. + +use std::ffi::{CStr, CString}; +use std::fmt::Write as FmtWrite; +use std::os::raw::c_char; +use std::path::Path; +use std::ptr; + +mod error; +pub use error::OwsGoError; + +/// Sentinel value for "no index provided" (uses account index 0). +const INDEX_NONE: u32 = u32::MAX; + +// --------------------------------------------------------------------------- +// Thread-local error state +// --------------------------------------------------------------------------- + +thread_local! { + static LAST_ERROR_CODE: std::cell::Cell = const { std::cell::Cell::new(0) }; + static LAST_ERROR_MSG: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +fn set_error(code: i32, msg: &str) { + let cmsg = CString::new(msg.to_string()).unwrap_or_else(|_| CString::new("unknown").unwrap()); + LAST_ERROR_MSG.with(|cell| { + *cell.borrow_mut() = Some(cmsg); + }); + LAST_ERROR_CODE.with(|cell| { + cell.set(code); + }); +} + +fn clear_error() { + LAST_ERROR_MSG.with(|cell| { + *cell.borrow_mut() = None; + }); + LAST_ERROR_CODE.with(|cell| { + cell.set(0); + }); +} + +// --------------------------------------------------------------------------- +// Memory management +// --------------------------------------------------------------------------- + +/// Free a heap-allocated string returned by this library. +/// +/// # Safety +/// - `s` must be a pointer returned by a function in this library. +/// - After calling this, do not use the pointer again. +#[allow(clippy::not_unsafe_ptr_arg_deref)] +#[no_mangle] +pub extern "C" fn ows_go_free_string(s: *mut c_char) { + if s.is_null() { + return; + } + unsafe { + let _ = CString::from_raw(s); + } +} + +/// Get the last error message, or NULL if no error. +/// The returned pointer is valid until the next ows_go_* call in this thread. +#[no_mangle] +pub extern "C" fn ows_go_get_last_error() -> *const c_char { + LAST_ERROR_MSG.with(|cell| match &*cell.borrow() { + Some(cstr) => cstr.as_ptr(), + None => ptr::null(), + }) +} + +/// Get the error code of the last error (0 = success). +#[no_mangle] +pub extern "C" fn ows_go_get_last_error_code() -> i32 { + LAST_ERROR_CODE.with(|cell| cell.get()) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn opt_path(s: *const c_char) -> Option<&'static Path> { + if s.is_null() { + None + } else { + Some(Path::new( + unsafe { CStr::from_ptr(s) }.to_str().unwrap_or(""), + )) + } +} + +fn opt_str(s: *const c_char) -> Option<&'static str> { + if s.is_null() { + None + } else { + Some(unsafe { CStr::from_ptr(s) }.to_str().unwrap_or("")) + } +} + +/// Serialize WalletInfo to JSON without serde_json. +fn wallet_info_to_json(info: &ows_lib::WalletInfo) -> String { + let mut json = String::new(); + json.push('{'); + write!(&mut json, r#""id":"{}","#, info.id).unwrap(); + write!(&mut json, r#""name":"{}","#, info.name).unwrap(); + json.push_str(r#""accounts":["#); + for (i, acct) in info.accounts.iter().enumerate() { + if i > 0 { + json.push(','); + } + json.push('{'); + write!(&mut json, r#""chain_id":"{}","#, acct.chain_id).unwrap(); + write!(&mut json, r#""address":"{}","#, acct.address).unwrap(); + write!(&mut json, r#""derivation_path":"{}""#, acct.derivation_path).unwrap(); + json.push('}'); + } + json.push_str("],"); + write!(&mut json, r#""created_at":"{}""#, info.created_at).unwrap(); + json.push('}'); + json +} + +// --------------------------------------------------------------------------- +// Wallet Operations (v1) +// --------------------------------------------------------------------------- + +/// Create a new universal wallet. +/// +/// # Parameters +/// - `name`: Wallet name (UTF-8) +/// - `passphrase`: Encryption passphrase, or NULL for empty +/// - `words`: BIP-39 word count (12/15/18/21/24); 0 = default (12) +/// - `vault_path`: Vault directory, or NULL for default (~/.ows) +/// +/// # Returns +/// JSON WalletInfo on success, NULL on failure. +/// Retrieve error via `ows_go_get_last_error_code()` / `ows_go_get_last_error()`. +#[allow(clippy::not_unsafe_ptr_arg_deref)] +#[no_mangle] +pub extern "C" fn ows_go_create_wallet( + name: *const c_char, + passphrase: *const c_char, + words: u32, + vault_path: *const c_char, +) -> *mut c_char { + let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or(""); + let vault_path = opt_path(vault_path); + + let result = ows_lib::create_wallet( + name, + if words == 0 { None } else { Some(words) }, + opt_str(passphrase), + vault_path, + ); + + match result { + Ok(info) => { + clear_error(); + let json = wallet_info_to_json(&info); + CString::new(json) + .unwrap_or_else(|_| CString::new("{}").unwrap()) + .into_raw() + } + Err(e) => { + let msg = e.to_string(); + set_error(OwsGoError::from_error_msg(&msg) as i32, &msg); + ptr::null_mut() + } + } +} + +/// List all wallets in the vault. +/// +/// # Parameters +/// - `vault_path`: Vault directory, or NULL for default (~/.ows) +/// +/// # Returns +/// JSON array of WalletInfo objects on success, NULL on failure. +#[no_mangle] +pub extern "C" fn ows_go_list_wallets(vault_path: *const c_char) -> *mut c_char { + let vault_path = opt_path(vault_path); + + match ows_lib::list_wallets(vault_path) { + Ok(wallets) => { + clear_error(); + let json_list: Vec = wallets.iter().map(wallet_info_to_json).collect(); + let json = format!("[{}]", json_list.join(",")); + CString::new(json) + .unwrap_or_else(|_| CString::new("[]").unwrap()) + .into_raw() + } + Err(e) => { + let msg = e.to_string(); + set_error(OwsGoError::from_error_msg(&msg) as i32, &msg); + ptr::null_mut() + } + } +} + +// --------------------------------------------------------------------------- +// Signing Operations (v1) +// --------------------------------------------------------------------------- + +/// Sign a message for a given chain. +#[allow(clippy::not_unsafe_ptr_arg_deref)] +#[no_mangle] +pub extern "C" fn ows_go_sign_message( + wallet: *const c_char, + chain: *const c_char, + message: *const c_char, + passphrase: *const c_char, + encoding: *const c_char, + index: u32, + vault_path: *const c_char, +) -> *mut c_char { + let wallet = unsafe { CStr::from_ptr(wallet) }.to_str().unwrap_or(""); + let chain = unsafe { CStr::from_ptr(chain) }.to_str().unwrap_or(""); + let message = unsafe { CStr::from_ptr(message) }.to_str().unwrap_or(""); + let vault_path = opt_path(vault_path); + let index = if index == INDEX_NONE { + None + } else { + Some(index) + }; + + match ows_lib::sign_message( + wallet, + chain, + message, + opt_str(passphrase), + opt_str(encoding), + index, + vault_path, + ) { + Ok(result) => { + clear_error(); + let json = format!( + r#"{{"signature":"{}","recovery_id":{}}}"#, + result.signature, + result + .recovery_id + .map(|v| v.to_string()) + .unwrap_or_else(|| "null".to_string()) + ); + CString::new(json) + .unwrap_or_else(|_| CString::new("{}").unwrap()) + .into_raw() + } + Err(e) => { + let msg = e.to_string(); + set_error(OwsGoError::from_error_msg(&msg) as i32, &msg); + ptr::null_mut() + } + } +} + +/// Sign a raw transaction. +#[allow(clippy::not_unsafe_ptr_arg_deref)] +#[no_mangle] +pub extern "C" fn ows_go_sign_transaction( + wallet: *const c_char, + chain: *const c_char, + tx_hex: *const c_char, + passphrase: *const c_char, + index: u32, + vault_path: *const c_char, +) -> *mut c_char { + let wallet = unsafe { CStr::from_ptr(wallet) }.to_str().unwrap_or(""); + let chain = unsafe { CStr::from_ptr(chain) }.to_str().unwrap_or(""); + let tx_hex = unsafe { CStr::from_ptr(tx_hex) }.to_str().unwrap_or(""); + let vault_path = opt_path(vault_path); + let index = if index == INDEX_NONE { + None + } else { + Some(index) + }; + + match ows_lib::sign_transaction( + wallet, + chain, + tx_hex, + opt_str(passphrase), + index, + vault_path, + ) { + Ok(result) => { + clear_error(); + let json = format!( + r#"{{"signature":"{}","recovery_id":{}}}"#, + result.signature, + result + .recovery_id + .map(|v| v.to_string()) + .unwrap_or_else(|| "null".to_string()) + ); + CString::new(json) + .unwrap_or_else(|_| CString::new("{}").unwrap()) + .into_raw() + } + Err(e) => { + let msg = e.to_string(); + set_error(OwsGoError::from_error_msg(&msg) as i32, &msg); + ptr::null_mut() + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_code_from_msg_wallet_not_found() { + assert_eq!( + OwsGoError::from_error_msg("wallet not found: 'foo'"), + OwsGoError::WalletNotFound + ); + } + + #[test] + fn test_error_code_unknown() { + assert_eq!( + OwsGoError::from_error_msg("totally unknown error"), + OwsGoError::Unknown + ); + } + + #[test] + fn test_clear_and_set_error() { + clear_error(); + assert_eq!(ows_go_get_last_error_code(), 0); + assert!(ows_go_get_last_error().is_null()); + + set_error(42, "test error"); + assert_eq!(ows_go_get_last_error_code(), 42); + let err_ptr = ows_go_get_last_error(); + assert!(!err_ptr.is_null()); + let err_str = unsafe { CStr::from_ptr(err_ptr) }.to_str().unwrap(); + assert_eq!(err_str, "test error"); + + set_error(1, "another error"); + assert_eq!(ows_go_get_last_error_code(), 1); + } + + #[test] + fn test_index_none_constant() { + assert_eq!(INDEX_NONE, u32::MAX); + } +}