diff --git a/.gitignore b/.gitignore index 723ef36..1f1025f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.idea \ No newline at end of file +.idea +.DS_Store \ No newline at end of file diff --git a/packages/r/intermarch3/goo-cli/.gitignore b/packages/r/intermarch3/goo-cli/.gitignore new file mode 100644 index 0000000..8992722 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/.gitignore @@ -0,0 +1,27 @@ +# Binaries +build/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/packages/r/intermarch3/goo-cli/Makefile b/packages/r/intermarch3/goo-cli/Makefile new file mode 100644 index 0000000..b143c8b --- /dev/null +++ b/packages/r/intermarch3/goo-cli/Makefile @@ -0,0 +1,78 @@ +.PHONY: build install test clean fmt lint help + +# Binary name +BINARY_NAME=goo +BUILD_DIR=build + +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOINSTALL=$(GOCMD) install +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get +GOFMT=$(GOCMD) fmt + +# Build the binary +build: + @echo "Building $(BINARY_NAME)..." + @mkdir -p $(BUILD_DIR) + $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/goo + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" + +# Install the binary to $GOPATH/bin +install: + @echo "Installing $(BINARY_NAME)..." + $(GOINSTALL) ./cmd/goo + @echo "Install complete!" + +# Run tests +test: + @echo "Running tests..." + $(GOTEST) -v ./... + +# Clean build artifacts +clean: + @echo "Cleaning..." + $(GOCLEAN) + rm -rf $(BUILD_DIR) + @echo "Clean complete!" + +# Format code +fmt: + @echo "Formatting code..." + $(GOFMT) ./... + @echo "Format complete!" + +# Lint code (requires golangci-lint) +lint: + @echo "Linting code..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint run; \ + else \ + echo "golangci-lint not installed. Install it from https://golangci-lint.run/"; \ + fi + +# Download dependencies +deps: + @echo "Downloading dependencies..." + $(GOGET) -v ./... + @echo "Dependencies downloaded!" + +# Run the CLI (for testing) +run: + @$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/goo + @./$(BUILD_DIR)/$(BINARY_NAME) + +# Show help +help: + @echo "Available targets:" + @echo " build - Build the binary" + @echo " install - Install the binary to \$$GOPATH/bin" + @echo " test - Run tests" + @echo " clean - Clean build artifacts" + @echo " fmt - Format code" + @echo " lint - Lint code (requires golangci-lint)" + @echo " deps - Download dependencies" + @echo " run - Build and run the CLI" + @echo " help - Show this help message" diff --git a/packages/r/intermarch3/goo-cli/README.md b/packages/r/intermarch3/goo-cli/README.md new file mode 100644 index 0000000..5a3767a --- /dev/null +++ b/packages/r/intermarch3/goo-cli/README.md @@ -0,0 +1,330 @@ +# Goo Oracle CLI + +Command-line interface for the Gno Optimistic Oracle (GOO). + +## Features + +- **Request Management**: Create data requests with custom parameters (auto-queries default reward) +- **Value Proposals**: Propose values for pending requests (auto-queries and sends required bond) +- **AI-Powered Proposals**: Automatically research and propose values using Google Gemini AI with web search +- **Dispute System**: Challenge proposed values and participate in voting (auto-queries and sends required bond) +- **Voting Mechanism**: Commit-reveal voting with local vote storage (auto-queries vote token price) +- **Query Operations**: Read oracle state and parameters +- **Admin Functions**: Manage oracle configuration (requires admin privileges) +- **Verbose Mode**: Use `--verbose` or `-v` flag to see detailed gnokey output +- **Key Override**: Use `--key` flag to override the configured key name for any command +- **User-Friendly Errors**: Automatic parsing of contract errors into friendly messages + +## Installation + +```bash +# Install dependencies +go mod download + +# use make +make build + +# Install globally +make install +``` + +## Configuration + +Initialize your configuration: + +```bash +goo config init +``` + +This creates `~/.goo/config.yaml` with default values. Edit this file to customize: + +```yaml +keyname: mykey +realm_path: gno.land/r/intermarch3/goo +chain_id: test4 +remote: https://rpc.test4.gno.land:443 +gas_fee: 1000000ugnot +gas_wanted: 2000000 +google_api_key: "" # Optional: for AI-powered proposals +``` + +View current configuration: + +```bash +goo config show +``` + +## Usage + +### Global Flags + +All commands support these global flags: +- `--key `: Override the key name from config +- `--verbose` or `-v`: Enable verbose output (shows full gnokey commands and output) + +### Request Commands + +**Create a new request:** +```bash +# Numeric question (reward auto-queried if not specified) +goo request create \ + --question "What is the ETH/USD price on 2025-10-27 12:00 UTC?" \ + --deadline "2025-10-28T12:00:00Z" + +# Yes/No question +goo request create \ + --question "Did BTC reach $100,000 by 2025-10-27?" \ + --yesno \ + --deadline "2025-10-28T12:00:00Z" + +# With custom reward +goo request create \ + --question "ETH/USD price?" \ + --deadline "2025-10-28T12:00:00Z" \ + --reward 2000000 +``` + +**Get request details:** +```bash +goo request get 0000001 +``` + +**Retrieve unfulfilled request funds:** +```bash +goo request retrieve-fund 0000001 +``` + +### Propose Commands + +**Propose a value (manual):** +```bash +goo propose value 0000001 3500 +``` + +**Propose a value (AI-powered with web search):** +```bash +goo propose value 0000001 --search +``` + +**Resolve a non-disputed request:** +```bash +goo propose resolve 0000001 +``` + +### Dispute Commands + +**Create a dispute:** +```bash +goo dispute create 0000001 +``` + +**Get dispute details:** +```bash +goo dispute get 0000001 +``` + +**Resolve a dispute:** +```bash +goo dispute resolve 0000001 +``` + +### Vote Commands + +**Buy vote token:** +```bash +goo vote buy-token +``` + +**Check vote balance:** +```bash +goo vote balance +``` + +**Commit a vote:** +```bash +# With auto-generated salt +goo vote commit 0000001 3500 + +# With custom salt +goo vote commit 0000001 3500 --salt my-random-salt +``` + +**Reveal a vote:** +```bash +goo vote reveal 0000001 +``` + +### Query Commands + +**Get request result:** +```bash +goo query result 0000001 +``` + +**Get oracle parameters:** +```bash +goo query params +``` + +**List requests:** +```bash +goo query list +goo query list --state Proposed +``` + +### Admin Commands + +**Set resolution duration:** +```bash +goo admin set-resolution-duration 120 +``` + +**Set requester reward:** +```bash +goo admin set-reward 2000000 +``` + +**Set bond amount:** +```bash +goo admin set-bond 3000000 +``` + +**Change admin:** +```bash +goo admin change-admin g1abcdef... +``` + +## Typical Workflow + +1. **Setup:** + ```bash + goo config init + ``` + +2. **Create a request:** + ```bash + goo request create --question "ETH/USD?" --deadline "2025-10-28T12:00:00Z" + ``` + +3. **Propose a value (manual or AI-powered):** + ```bash + # Manual + goo propose value 0000001 3500 + + # Or let AI research it + goo propose value 0000001 --search + ``` + +4. **Someone disputes (if they disagree):** + ```bash + goo dispute create 0000001 + ``` + +5. **Buy vote token (if needed):** + ```bash + goo vote buy-token + ``` + +6. **Commit vote:** + ```bash + goo vote commit 0000001 3500 + ``` + +7. **Reveal vote (after voting period):** + ```bash + goo vote reveal 0000001 + ``` + +8. **Resolve dispute:** + ```bash + goo dispute resolve 0000001 + ``` + +9. **Check result:** + ```bash + goo query result 0000001 + ``` + +## Vote Data Storage + +When you commit a vote, the CLI automatically saves your vote data locally at: + +``` +~/.goo/votes/.json +``` + +This file contains: +- Request ID +- Vote value +- Salt used for hashing +- Generated hash +- Timestamp + +This data is automatically loaded when you reveal your vote. + +## Advanced Features + +### Verbose Mode + +By default, the CLI shows clean, minimal output with user-friendly error messages. Use `--verbose` or `-v` to see: +- Full gnokey commands being executed +- Complete transaction output including TX hash and gas info +- Detailed error messages and stack traces + +Example: +```bash +# Default mode (clean output) +goo propose value 0000001 3500 + +# Verbose mode (detailed output) +goo propose value 0000001 3500 --verbose +``` + +### Key Override + +Override the configured key name for a single command without modifying your config: + +```bash +# Use a different key for this transaction +goo propose value 0000001 3500 --key myotherkey +``` + +### Error Handling + +The CLI automatically parses contract errors and displays friendly messages: +- ❌ Request not found - invalid request ID +- ❌ Proposal deadline has passed +- ❌ You need to buy a vote token first ('goo vote buy-token') +- And 30+ more error patterns + +Unknown errors display the full error message for debugging. + +## Project Structure + +``` +goo-cli/ +├── cmd/goo/ # Main entry point +├── internal/ +│ ├── commands/ # Command implementations +│ │ ├── request.go # Request commands +│ │ ├── propose.go # Propose commands +│ │ ├── dispute.go # Dispute commands +│ │ ├── vote.go # Vote commands +│ │ ├── query.go # Query commands +│ │ ├── admin.go # Admin commands +│ │ └── config.go # Config commands +│ ├── gnokey/ # gnokey execution wrapper +│ │ └── executor.go # Transaction and query execution +│ ├── config/ # Configuration management +│ │ └── config.go # Config loading and key override +│ └── utils/ # Utility functions +│ ├── errors.go # Error parsing and friendly messages +│ ├── format.go # Formatting utilities +│ └── print.go # Output helpers +└── pkg/types/ # Type definitions +``` + +## Developer + +| [
Lucas Leclerc](https://github.com/intermarch3) | +| :---: | \ No newline at end of file diff --git a/packages/r/intermarch3/goo-cli/cmd/goo/main.go b/packages/r/intermarch3/goo-cli/cmd/goo/main.go new file mode 100644 index 0000000..ce044b9 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/cmd/goo/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "goo-cli/internal/commands" +) + +func main() { + if err := commands.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/packages/r/intermarch3/goo-cli/go.mod b/packages/r/intermarch3/goo-cli/go.mod new file mode 100644 index 0000000..a55cc4b --- /dev/null +++ b/packages/r/intermarch3/goo-cli/go.mod @@ -0,0 +1,62 @@ +module goo-cli + +go 1.21 + +require ( + github.com/google/generative-ai-go v0.18.0 + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 + google.golang.org/api v0.203.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/ai v0.8.0 // indirect + cloud.google.com/go/auth v0.9.9 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect + cloud.google.com/go/compute/metadata v0.5.2 // indirect + cloud.google.com/go/longrunning v0.5.7 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.13.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/time v0.7.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect + google.golang.org/grpc v1.67.1 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) diff --git a/packages/r/intermarch3/goo-cli/go.sum b/packages/r/intermarch3/goo-cli/go.sum new file mode 100644 index 0000000..292f3d2 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/go.sum @@ -0,0 +1,220 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= +cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= +cloud.google.com/go/auth v0.9.9 h1:BmtbpNQozo8ZwW2t7QJjnrQtdganSdmqeIBxHxNkEZQ= +cloud.google.com/go/auth v0.9.9/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= +cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= +cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= +cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= +cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= +cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU= +cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/generative-ai-go v0.18.0 h1:6ybg9vOCLcI/UpBBYXOTVgvKmcUKFRNj+2Cj3GnebSo= +github.com/google/generative-ai-go v0.18.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= +github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.203.0 h1:SrEeuwU3S11Wlscsn+LA1kb/Y5xT8uggJSkIhD08NAU= +google.golang.org/api v0.203.0/go.mod h1:BuOVyCSYEPwJb3npWvDnNmFI92f3GeRnHNkETneT3SI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/packages/r/intermarch3/goo-cli/internal/commands/admin.go b/packages/r/intermarch3/goo-cli/internal/commands/admin.go new file mode 100644 index 0000000..5c54861 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/commands/admin.go @@ -0,0 +1,174 @@ +package commands + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "goo-cli/internal/config" + "goo-cli/internal/gnokey" + "goo-cli/internal/utils" +) + +// NewAdminCmd creates the admin command +func NewAdminCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "admin", + Short: "Admin operations", + Long: "Administrative commands for managing oracle parameters (requires admin privileges)", + } + + cmd.AddCommand(NewAdminSetResolutionDurationCmd()) + cmd.AddCommand(NewAdminSetRewardCmd()) + cmd.AddCommand(NewAdminSetBondCmd()) + cmd.AddCommand(NewAdminChangeAdminCmd()) + + return cmd +} + +// NewAdminSetResolutionDurationCmd sets resolution duration +func NewAdminSetResolutionDurationCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set-resolution-duration ", + Short: "Set the resolution duration", + Long: "Update the time window for resolving non-disputed proposals (admin only)", + Args: cobra.ExactArgs(1), + Example: ` goo admin set-resolution-duration 120`, + RunE: func(cmd *cobra.Command, args []string) error { + duration, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid duration: %w", err) + } + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + utils.PrintWarning("This operation requires admin privileges!") + + // Execute transaction + funcArgs := []string{fmt.Sprintf("%d", duration)} + if err := executor.CallFunction("SetResolutionDuration", funcArgs, ""); err != nil { + return err + } + + utils.PrintSuccess("Resolution duration updated!") + utils.PrintInfo(fmt.Sprintf("New duration: %d seconds (%s)", duration, utils.FormatDuration(utils.DurationFromSeconds(duration)))) + + return nil + }, + } + + return cmd +} + +// NewAdminSetRewardCmd sets requester reward +func NewAdminSetRewardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set-reward ", + Short: "Set the requester reward amount", + Long: "Update the default reward amount for requesters (admin only)", + Args: cobra.ExactArgs(1), + Example: ` goo admin set-reward 2000000`, + RunE: func(cmd *cobra.Command, args []string) error { + amount, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid amount: %w", err) + } + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + utils.PrintWarning("This operation requires admin privileges!") + + // Execute transaction + funcArgs := []string{fmt.Sprintf("%d", amount)} + if err := executor.CallFunction("SetrequesterReward", funcArgs, ""); err != nil { + return err + } + + utils.PrintSuccess("Requester reward updated!") + utils.PrintInfo(fmt.Sprintf("New reward: %s", utils.FormatUgnot(amount))) + + return nil + }, + } + + return cmd +} + +// NewAdminSetBondCmd sets bond amount +func NewAdminSetBondCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set-bond ", + Short: "Set the bond amount", + Long: "Update the bond amount required for proposals and disputes (admin only)", + Args: cobra.ExactArgs(1), + Example: ` goo admin set-bond 3000000`, + RunE: func(cmd *cobra.Command, args []string) error { + amount, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid amount: %w", err) + } + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + utils.PrintWarning("This operation requires admin privileges!") + + // Execute transaction + funcArgs := []string{fmt.Sprintf("%d", amount)} + if err := executor.CallFunction("SetBond", funcArgs, ""); err != nil { + return err + } + + utils.PrintSuccess("Bond amount updated!") + utils.PrintInfo(fmt.Sprintf("New bond: %s", utils.FormatUgnot(amount))) + + return nil + }, + } + + return cmd +} + +// NewAdminChangeAdminCmd changes the admin address +func NewAdminChangeAdminCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "change-admin
", + Short: "Transfer admin privileges", + Long: "Change the admin address to a new address (admin only)", + Args: cobra.ExactArgs(1), + Example: ` goo admin change-admin g1abcdef...`, + RunE: func(cmd *cobra.Command, args []string) error { + newAdmin := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + utils.PrintWarning("This operation requires admin privileges!") + utils.PrintWarning(fmt.Sprintf("You are transferring admin rights to: %s", newAdmin)) + + // Execute transaction + funcArgs := []string{newAdmin} + if err := executor.CallFunction("ChangeAdmin", funcArgs, ""); err != nil { + return err + } + + utils.PrintSuccess("Admin changed successfully!") + utils.PrintInfo(fmt.Sprintf("New admin: %s", newAdmin)) + + return nil + }, + } + + return cmd +} diff --git a/packages/r/intermarch3/goo-cli/internal/commands/config.go b/packages/r/intermarch3/goo-cli/internal/commands/config.go new file mode 100644 index 0000000..105d77f --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/commands/config.go @@ -0,0 +1,207 @@ +package commands + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "goo-cli/internal/config" + "goo-cli/internal/utils" +) + +// NewConfigCmd creates the config command +func NewConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage CLI configuration", + Long: "Initialize and manage the CLI configuration file", + } + + cmd.AddCommand(NewConfigInitCmd()) + cmd.AddCommand(NewConfigShowCmd()) + + return cmd +} + +// NewConfigInitCmd initializes the config file +func NewConfigInitCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize configuration file", + Long: "Create a new configuration file with default values at ~/.goo/config.yaml", + Example: ` goo config init + goo config init --force # Overwrite existing config`, + RunE: func(cmd *cobra.Command, args []string) error { + configPath, err := config.GetConfigPath() + if err != nil { + return err + } + + // Check if config already exists + if _, err := os.Stat(configPath); err == nil { + if !force { + return fmt.Errorf("config file already exists at %s (use --force to overwrite)", configPath) + } + utils.PrintWarning("Overwriting existing configuration...") + } + + reader := bufio.NewReader(os.Stdin) + + // Create config with default values + cfg := config.DefaultConfig() + + fmt.Println() + fmt.Println("🔧 GOO CLI Configuration") + fmt.Println("═══════════════════════════") + fmt.Println() + + // 1. Ask for key name + fmt.Print("Enter your gnokey name (default: test): ") + keyName, _ := reader.ReadString('\n') + keyName = strings.TrimSpace(keyName) + if keyName != "" { + cfg.KeyName = keyName + } + + // 2. Ask for network type + fmt.Println() + fmt.Println("Select network:") + fmt.Println(" 1. Development (local dev network)") + fmt.Println(" 2. Custom (specify chain ID and remote)") + fmt.Print("Choice [1/2] (default: 1): ") + + choice, _ := reader.ReadString('\n') + choice = strings.TrimSpace(choice) + + if choice == "2" { + // Custom network + fmt.Println() + fmt.Print("Enter Chain ID: ") + chainID, _ := reader.ReadString('\n') + chainID = strings.TrimSpace(chainID) + if chainID != "" { + cfg.ChainID = chainID + } + + fmt.Print("Enter Remote URL: ") + remote, _ := reader.ReadString('\n') + remote = strings.TrimSpace(remote) + if remote != "" { + cfg.Remote = remote + } + } else { + // Dev network (default) + cfg.ChainID = "dev" + cfg.Remote = "tcp://127.0.0.1:26657" + utils.PrintInfo("Using dev network (chain_id: dev, remote: tcp://127.0.0.1:26657)") + } + + // 3. Ask for Google API key (optional) + fmt.Println() + fmt.Println("🔍 AI-Powered Proposals (Optional)") + fmt.Println("───────────────────────────────────") + fmt.Println("Enable AI to automatically research and propose values using Google Gemini.") + fmt.Println() + fmt.Print("Enter Google API Key (leave empty to skip): ") + + apiKey, _ := reader.ReadString('\n') + apiKey = strings.TrimSpace(apiKey) + + if apiKey != "" { + cfg.GoogleAPIKey = apiKey + utils.PrintSuccess("Google API key configured") + } else { + utils.PrintInfo("Skipped - you can add it later in ~/.goo/config.yaml") + } + + // Save config + if err := config.Save(cfg); err != nil { + return err + } + + // Display summary + fmt.Println() + utils.PrintSuccess(fmt.Sprintf("Config file created at %s", configPath)) + fmt.Println() + fmt.Println("Configuration:") + fmt.Printf(" Key Name: %s\n", cfg.KeyName) + fmt.Printf(" Realm Path: %s\n", cfg.RealmPath) + fmt.Printf(" Chain ID: %s\n", cfg.ChainID) + fmt.Printf(" Remote: %s\n", cfg.Remote) + fmt.Printf(" Gas Fee: %s\n", cfg.GasFee) + fmt.Printf(" Gas Wanted: %d\n", cfg.GasWanted) + if cfg.GoogleAPIKey != "" { + maskedKey := cfg.GoogleAPIKey + if len(maskedKey) > 8 { + maskedKey = maskedKey[:8] + "..." + } + fmt.Printf(" Google API Key: %s\n", maskedKey) + } else { + fmt.Printf(" Google API Key: (not configured)\n") + } + fmt.Println() + fmt.Println("💡 You can edit this file anytime: ~/.goo/config.yaml") + + if cfg.GoogleAPIKey == "" { + fmt.Println() + fmt.Println("To enable AI-powered proposals later:") + fmt.Println(" 1. Get a free API key: https://makersuite.google.com/app/apikey") + fmt.Println(" 2. Edit ~/.goo/config.yaml and add:") + fmt.Println(" google_api_key: your-api-key-here") + fmt.Println(" 3. Use: goo propose value --search") + } + + return nil + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, "Overwrite existing configuration file") + + return cmd +} + +// NewConfigShowCmd displays the current configuration +func NewConfigShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "Show current configuration", + Long: "Display the current CLI configuration", + Example: ` goo config show`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.Load() + + utils.PrintSection("Current Configuration") + utils.PrintKeyValue("Key Name", cfg.KeyName) + utils.PrintKeyValue("Realm Path", cfg.RealmPath) + utils.PrintKeyValue("Chain ID", cfg.ChainID) + utils.PrintKeyValue("Remote", cfg.Remote) + utils.PrintKeyValue("Gas Fee", cfg.GasFee) + utils.PrintKeyValue("Gas Wanted", cfg.GasWanted) + + if cfg.GoogleAPIKey != "" { + maskedKey := cfg.GoogleAPIKey + if len(maskedKey) > 8 { + maskedKey = maskedKey[:8] + "..." + } + utils.PrintKeyValue("Google API Key", maskedKey) + } else { + utils.PrintKeyValue("Google API Key", "(not configured)") + } + fmt.Println() + + configPath, err := config.GetConfigPath() + if err == nil { + utils.PrintInfo(fmt.Sprintf("Config file: %s", configPath)) + } + + return nil + }, + } + + return cmd +} diff --git a/packages/r/intermarch3/goo-cli/internal/commands/dispute.go b/packages/r/intermarch3/goo-cli/internal/commands/dispute.go new file mode 100644 index 0000000..2d20480 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/commands/dispute.go @@ -0,0 +1,163 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "goo-cli/internal/config" + "goo-cli/internal/gnokey" + "goo-cli/internal/utils" +) + +// NewDisputeCmd creates the dispute command +func NewDisputeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dispute", + Short: "Manage disputes", + Long: "Create, query, and resolve disputes on proposed values", + } + + cmd.AddCommand(NewDisputeCreateCmd()) + cmd.AddCommand(NewDisputeGetCmd()) + cmd.AddCommand(NewDisputeResolveCmd()) + + return cmd +} + +// NewDisputeCreateCmd creates a new dispute +func NewDisputeCreateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a dispute on a proposed value", + Long: "Challenge a proposed value by creating a dispute. Requires bond to be sent with the transaction.", + Args: cobra.ExactArgs(1), + Example: ` goo dispute create 0000001`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Query the required bond amount from contract + utils.PrintInfo("Querying required bond amount from contract...") + bond, err := executor.QueryInt64("GetBond") + if err != nil { + return fmt.Errorf("failed to query bond amount: %w", err) + } + + utils.PrintInfo(fmt.Sprintf("Bond required: %d ugnot", bond)) + + // Execute transaction with bond + sendAmount := fmt.Sprintf("%dugnot", bond) + if err := executor.CallFunction("DisputeData", []string{requestID}, sendAmount); err != nil { + return err + } + + utils.PrintSuccess("Dispute created successfully!") + utils.PrintInfo(fmt.Sprintf("Request ID: %s", requestID)) + utils.PrintInfo("Voting period has started") + utils.PrintInfo(fmt.Sprintf("Bond sent: %d ugnot", bond)) + + return nil + }, + } + + return cmd +} + +// NewDisputeGetCmd gets details of a dispute +func NewDisputeGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get details of a dispute", + Long: "Retrieve details about a dispute including vote counts and timing", + Args: cobra.ExactArgs(1), + Example: ` goo dispute get 0000001`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Query the dispute + result, err := executor.QueryFunction("GetDispute", []string{requestID}) + if err != nil { + return err + } + + // Parse the dispute data + dispute, err := utils.ParseDisputeFromQuery(result) + if err != nil { + // If parsing fails, show raw output in verbose mode + if verbose { + utils.PrintError(fmt.Sprintf("Failed to parse dispute: %v", err)) + fmt.Println(result) + } + return fmt.Errorf("failed to parse dispute data: %w", err) + } + + // Display dispute information in a clean format + utils.PrintSection(fmt.Sprintf("Dispute for Request %s", dispute.RequestID)) + fmt.Println() + + // Status Information + fmt.Println("Status:") + utils.PrintKeyValue(" Request ID", dispute.RequestID) + if dispute.IsResolved { + utils.PrintKeyValue(" Status", "Resolved") + utils.PrintKeyValue(" Winning Value", dispute.WinningValue) + } else { + utils.PrintKeyValue(" Status", "Active") + } + + // Voting Information + fmt.Println() + fmt.Println("Voting:") + utils.PrintKeyValue(" Total Votes", dispute.Votes) + utils.PrintKeyValue(" Revealed Votes", dispute.NbResolvedVotes) + unrevealed := int64(dispute.Votes) - dispute.NbResolvedVotes + utils.PrintKeyValue(" Unrevealed Votes", unrevealed) + fmt.Println() + + return nil + }, + } + + return cmd +} + +// NewDisputeResolveCmd resolves a dispute +func NewDisputeResolveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "resolve ", + Short: "Resolve a dispute after voting period", + Long: "Finalize a dispute after the reveal period has ended", + Args: cobra.ExactArgs(1), + Example: ` goo dispute resolve 0000001`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Execute transaction + if err := executor.CallFunction("ResolveDispute", []string{requestID}, ""); err != nil { + return err + } + + utils.PrintSuccess("Dispute resolution submitted!") + utils.PrintInfo(fmt.Sprintf("Request ID: %s", requestID)) + + return nil + }, + } + + return cmd +} diff --git a/packages/r/intermarch3/goo-cli/internal/commands/propose.go b/packages/r/intermarch3/goo-cli/internal/commands/propose.go new file mode 100644 index 0000000..a4b7f37 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/commands/propose.go @@ -0,0 +1,288 @@ +package commands + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "goo-cli/internal/config" + "goo-cli/internal/gnokey" + "goo-cli/internal/search_agent" + "goo-cli/internal/utils" +) + +// NewProposeCmd creates the propose command +func NewProposeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "propose", + Short: "Propose values for requests", + Long: "Propose a value for a data request or resolve a request", + } + + cmd.AddCommand(NewProposeValueCmd()) + cmd.AddCommand(NewProposeResolveCmd()) + + return cmd +} + +// NewProposeValueCmd proposes a value for a request +func NewProposeValueCmd() *cobra.Command { + var searchFlag bool + + cmd := &cobra.Command{ + Use: "value [value]", + Short: "Propose a value for a request", + Long: "Propose a value for a data request. Requires bond to be sent with the transaction. Use --search to automatically research the value using AI.", + Args: cobra.RangeArgs(1, 2), + Example: ` # Manual proposal + goo propose value 0000001 3500 + + # AI-powered proposal with web search + goo propose value 0000001 --search + + # With custom key + goo propose value 0000001 --search --key mykey`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + var value string + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // If --search flag is used, query AI for the value + if searchFlag { + // Check if API key is configured + if cfg.GoogleAPIKey == "" { + return fmt.Errorf("❌ Google API key not configured. Run 'goo config init' or set it manually in ~/.goo/config.yaml") + } + + // Query request details from smart contract + utils.PrintInfo(fmt.Sprintf("Fetching request details for ID: %s", requestID)) + requestResult, err := executor.QueryFunction("GetRequest", []string{requestID}) + if err != nil { + return fmt.Errorf("failed to fetch request details: %w", err) + } + + // Parse the request to get the question + req, err := utils.ParseDataRequestFromQuery(requestResult) + if err != nil { + return fmt.Errorf("failed to parse request: %w", err) + } + + question := req.AncillaryData + isYesNo := req.YesNoQuestion + + fmt.Println() + fmt.Printf("Question: %s\n", question) + fmt.Println() + + // Initialize Gemini client + geminiClient, err := search_agent.NewGeminiClient(cfg.GoogleAPIKey, verbose) + if err != nil { + fmt.Println() + utils.PrintError(fmt.Sprintf("Failed to initialize AI client: %v", err)) + fmt.Println() + return nil // Exit gracefully, error already displayed + } + defer geminiClient.Close() + + // Query Gemini for the answer + response, err := geminiClient.QueryQuestion(question) + if err != nil { + fmt.Println() + utils.PrintError(fmt.Sprintf("AI research failed: %v", err)) + fmt.Println() + return nil // Exit gracefully, error already displayed + } + + // Check for special error cases + if response.Value == "FUTURE_QUESTION_ERROR" { + fmt.Println() + utils.PrintError("This question is about a future event") + utils.PrintError("Oracle cannot predict the future - only answer verifiable questions") + fmt.Println() + return nil // Exit gracefully, error already displayed + } + + if response.Value == "INSUFFICIENT DATA" { + fmt.Println() + utils.PrintWarning("AI could not find sufficient data to answer this question") + fmt.Println() + fmt.Println("Reason:") + fmt.Println(response.Why) + fmt.Println() + return nil // Exit gracefully, error already displayed + } + + // Validate and convert value based on question type + proposedValue := strings.TrimSpace(response.Value) + + if isYesNo { + // For yes/no questions, convert to 0 or 1 + normalizedValue := strings.ToLower(proposedValue) + if normalizedValue == "yes" { + proposedValue = "1" + } else if normalizedValue == "no" { + proposedValue = "0" + } else { + fmt.Println() + utils.PrintError(fmt.Sprintf("Invalid yes/no answer from AI: '%s'", response.Value)) + fmt.Println("Expected: 'Yes' or 'No'") + fmt.Println() + return nil // Exit gracefully, error already displayed + } + } else { + // For numeric questions, validate it's a valid number + if !isValidNumber(proposedValue) { + fmt.Println() + utils.PrintError(fmt.Sprintf("Invalid numeric answer from AI: '%s'", response.Value)) + fmt.Println("Expected: A pure number like '3874' or '3874.50'") + fmt.Println("The AI should return only the number without currency symbols, commas, or text.") + fmt.Println() + return nil // Exit gracefully, error already displayed + } + } + + // Display research results (clean output) + fmt.Println() + if isYesNo { + fmt.Printf("Answer: %s → %s\n", response.Value, proposedValue) + } else { + fmt.Printf("Answer: %s\n", proposedValue) + } + fmt.Println() + + if response.Why != "" { + fmt.Println("Justification:") + // Wrap the justification text at ~80 characters + words := strings.Fields(response.Why) + line := "" + for _, word := range words { + if len(line)+len(word)+1 > 80 { + fmt.Println(line) + line = word + } else { + if line != "" { + line += " " + } + line += word + } + } + if line != "" { + fmt.Println(line) + } + fmt.Println() + } + + if len(response.Sources) > 0 { + fmt.Println("Sources:") + for i, src := range response.Sources { + fmt.Printf(" %d. %s\n", i+1, src) + } + fmt.Println() + } + + // Ask for confirmation + fmt.Print("Propose this value? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + confirm, _ := reader.ReadString('\n') + confirm = strings.TrimSpace(strings.ToLower(confirm)) + + if confirm != "y" && confirm != "yes" { + utils.PrintInfo("Cancelled") + return nil + } + + value = proposedValue + fmt.Println() + + } else { + // Manual mode - value must be provided + if len(args) < 2 { + return fmt.Errorf("value argument required (or use --search flag for AI-powered proposal)") + } + value = args[1] + } + + // Query the required bond amount from contract + utils.PrintInfo("Querying required bond amount from contract...") + bond, err := executor.QueryInt64("GetBond") + if err != nil { + return fmt.Errorf("failed to query bond amount: %w", err) + } + + utils.PrintInfo(fmt.Sprintf("Bond required: %d ugnot", bond)) + fmt.Println() + + // Execute transaction with bond + funcArgs := []string{requestID, value} + sendAmount := fmt.Sprintf("%dugnot", bond) + + if err := executor.CallFunction("ProposeValue", funcArgs, sendAmount); err != nil { + return err + } + + utils.PrintSuccess("Value proposed successfully!") + utils.PrintInfo(fmt.Sprintf("Request ID: %s", requestID)) + utils.PrintInfo(fmt.Sprintf("Proposed Value: %s", value)) + utils.PrintInfo(fmt.Sprintf("Bond sent: %d ugnot", bond)) + + return nil + }, + } + + cmd.Flags().BoolVar(&searchFlag, "search", false, "Use AI-powered search to propose a value automatically") + + return cmd +} + +// isValidNumber checks if a string represents a valid number +// Accepts: integers, decimals with period, negative numbers +// Rejects: anything with non-numeric characters (including currency symbols, commas, text) +func isValidNumber(s string) bool { + if s == "" { + return false + } + + // Try to parse as float64 + // This validates the format without additional dependencies + var f float64 + _, err := fmt.Sscanf(s, "%f", &f) + return err == nil +} + +// NewProposeResolveCmd resolves a non-disputed request +func NewProposeResolveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "resolve ", + Short: "Resolve a non-disputed request", + Long: "Finalize a request that has not been disputed after the resolution time", + Args: cobra.ExactArgs(1), + Example: ` goo propose resolve 0000001`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Execute transaction + if err := executor.CallFunction("ResolveRequest", []string{requestID}, ""); err != nil { + return err + } + + utils.PrintSuccess("Request resolution submitted!") + utils.PrintInfo(fmt.Sprintf("Request ID: %s", requestID)) + + return nil + }, + } + + return cmd +} diff --git a/packages/r/intermarch3/goo-cli/internal/commands/query.go b/packages/r/intermarch3/goo-cli/internal/commands/query.go new file mode 100644 index 0000000..aba622f --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/commands/query.go @@ -0,0 +1,205 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "goo-cli/internal/config" + "goo-cli/internal/gnokey" + "goo-cli/internal/utils" +) + +// NewQueryCmd creates the query command +func NewQueryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "query", + Short: "Query oracle data", + Long: "Read-only queries for oracle state and parameters", + } + + cmd.AddCommand(NewQueryResultCmd()) + cmd.AddCommand(NewQueryParamsCmd()) + cmd.AddCommand(NewQueryListCmd()) + + return cmd +} + +// NewQueryResultCmd queries the result of a request +func NewQueryResultCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "result ", + Short: "Get the final result of a request", + Long: "Query the winning value for a resolved request (requires signing)", + Args: cobra.ExactArgs(1), + Example: ` goo query result 0000001`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Call as transaction since it requires realm context + if err := executor.CallFunction("RequestResult", []string{requestID}, ""); err != nil { + return err + } + + utils.PrintSuccess(fmt.Sprintf("Result query for request %s executed successfully!", requestID)) + + return nil + }, + } + + return cmd +} + +// NewQueryParamsCmd queries oracle parameters +func NewQueryParamsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "params", + Short: "Get oracle parameters", + Long: "Query all oracle configuration parameters", + Example: ` goo query params`, + RunE: func(cmd *cobra.Command, args []string) error { + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + utils.PrintSection("Oracle Parameters") + + // Query each parameter + params := []struct { + name string + funcName string + }{ + {"Bond", "GetBond"}, + {"Resolution Time", "GetResolutionTime"}, + {"Requester Reward", "GetRequesterReward"}, + {"Dispute Duration", "GetDisputeDuration"}, + {"Reveal Duration", "GetRevealDuration"}, + {"Vote Token Price", "GetVoteTokenPrice"}, + } + + for _, p := range params { + result, err := executor.QueryFunction(p.funcName, []string{}) + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to query %s: %v", p.name, err)) + continue + } + utils.PrintKeyValue(p.name, result) + } + + return nil + }, + } + + return cmd +} + +// NewQueryListCmd lists requests with their states +func NewQueryListCmd() *cobra.Command { + var stateFilter string + + cmd := &cobra.Command{ + Use: "list", + Short: "List all requests with their states", + Long: "Query and display all requests with their current states", + Example: ` goo query list + goo query list --state Proposed`, + RunE: func(cmd *cobra.Command, args []string) error { + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Validate state filter if provided + if stateFilter != "" { + validStates := []string{"Requested", "Proposed", "Disputed", "Resolved"} + isValid := false + for _, valid := range validStates { + if stateFilter == valid { + isValid = true + break + } + } + if !isValid { + return fmt.Errorf("invalid state '%s'. Valid states are: Requested, Proposed, Disputed, Resolved", stateFilter) + } + } + + // Query request IDs based on filter + var queryFunc string + var queryArgs []string + if stateFilter != "" { + queryFunc = "GetRequestsIdsWithState" + queryArgs = []string{stateFilter} + } else { + queryFunc = "GetRequestsIds" + queryArgs = []string{} + } + + result, err := executor.QueryFunction(queryFunc, queryArgs) + if err != nil { + return err + } + + // Parse the request IDs from the query result + requestIDs, err := utils.ParseStringArrayFromQuery(result) + if err != nil { + return fmt.Errorf("failed to parse request IDs: %w", err) + } + + if len(requestIDs) == 0 { + if stateFilter != "" { + utils.PrintInfo(fmt.Sprintf("No requests found with state: %s", stateFilter)) + } else { + utils.PrintInfo("No requests found") + } + return nil + } + + // Print header + if stateFilter != "" { + utils.PrintSuccess(fmt.Sprintf("Requests (filtered by state: %s)", stateFilter)) + } else { + utils.PrintSuccess("All Requests") + } + fmt.Println() + fmt.Printf("%-12s %-50s %-15s\n", "Request ID", "Question", "State") + fmt.Println(fmt.Sprintf("%s %s %s", "------------", "--------------------------------------------------", "---------------")) + + // Query and display details for each request + for _, id := range requestIDs { + // Get full request to extract question + requestResult, err := executor.QueryFunction("GetRequest", []string{id}) + if err != nil { + fmt.Printf("%-12s %-50s %-15s\n", id, "Error", "Error") + continue + } + + // Parse request to get question and state + req, err := utils.ParseDataRequestFromQuery(requestResult) + if err != nil { + fmt.Printf("%-12s %-50s %-15s\n", id, "Parse Error", "Error") + continue + } + + // Truncate question if too long + question := utils.TruncateString(req.AncillaryData, 50) + fmt.Printf("%-12s %-50s %-15s\n", id, question, req.State) + } + + fmt.Println() + utils.PrintInfo(fmt.Sprintf("Total: %d request(s)", len(requestIDs))) + + return nil + }, + } + + cmd.Flags().StringVar(&stateFilter, "state", "", "Filter by state: Requested, Proposed, Disputed, Resolved") + + return cmd +} diff --git a/packages/r/intermarch3/goo-cli/internal/commands/request.go b/packages/r/intermarch3/goo-cli/internal/commands/request.go new file mode 100644 index 0000000..ab64883 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/commands/request.go @@ -0,0 +1,229 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "goo-cli/internal/config" + "goo-cli/internal/gnokey" + "goo-cli/internal/utils" +) + +// NewRequestCmd creates the request command +func NewRequestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "request", + Short: "Manage data requests", + Long: "Create, query, and manage data requests in the oracle", + } + + cmd.AddCommand(NewRequestCreateCmd()) + cmd.AddCommand(NewRequestGetCmd()) + cmd.AddCommand(NewRequestRetrieveFundCmd()) + + return cmd +} + +// NewRequestCreateCmd creates a new data request +func NewRequestCreateCmd() *cobra.Command { + var ( + question string + yesno bool + deadline string + reward int64 + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new data request", + Long: "Create a new data request with specified question, type, deadline, and reward", + Example: ` goo request create \ + --question "What is the ETH/USD price on 2025-10-27 12:00 UTC?" \ + --deadline "2025-10-28T12:00:00Z" \ + --reward 1000000 + + # For yes/no questions, use --yesno flag + goo request create \ + --question "Did BTC reach $100,000 by 2025-10-27?" \ + --yesno \ + --deadline "2025-10-28T12:00:00Z" \ + --reward 1000000`, + RunE: func(cmd *cobra.Command, args []string) error { + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Parse deadline + deadlineTime, err := utils.ParseDeadline(deadline) + if err != nil { + return err + } + + // If reward is 0, query the default requester reward from contract + if reward == 0 { + utils.PrintInfo("Querying default requester reward from contract...") + reward, err = executor.QueryInt64("GetRequesterReward") + if err != nil { + return fmt.Errorf("failed to query requester reward: %w", err) + } + utils.PrintInfo(fmt.Sprintf("Default reward: %d ugnot", reward)) + } + + // Prepare function arguments + funcArgs := []string{ + question, // ancillaryData + utils.FormatBool(yesno), // yesNoQuestion + fmt.Sprintf("%d", deadlineTime.Unix()), // deadline + } + + sendAmount := fmt.Sprintf("%dugnot", reward) + + // Execute transaction + if err := executor.CallFunction("RequestData", funcArgs, sendAmount); err != nil { + return err + } + + utils.PrintSuccess("Request created successfully!") + utils.PrintInfo(fmt.Sprintf("Question: %s", question)) + if yesno { + utils.PrintInfo("Type: yes/no question") + } else { + utils.PrintInfo("Type: numeric") + } + utils.PrintInfo(fmt.Sprintf("Deadline: %s", utils.FormatTimeRFC3339(deadlineTime))) + utils.PrintInfo(fmt.Sprintf("Reward sent: %d ugnot", reward)) + + return nil + }, + } + + cmd.Flags().StringVar(&question, "question", "", "Question or ancillary data for the request") + cmd.Flags().BoolVar(&yesno, "yesno", false, "Set to true for yes/no questions (default: numeric)") + cmd.Flags().StringVar(&deadline, "deadline", "", "Deadline in RFC3339 format (e.g., 2025-10-28T12:00:00Z)") + cmd.Flags().Int64Var(&reward, "reward", 0, "Reward amount in ugnot (default: query from contract)") + + cmd.MarkFlagRequired("question") + cmd.MarkFlagRequired("deadline") + + return cmd +} + +// NewRequestGetCmd gets details of a request +func NewRequestGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get details of a specific request", + Args: cobra.ExactArgs(1), + Example: ` goo request get 0000001`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Query the request + result, err := executor.QueryFunction("GetRequest", []string{requestID}) + if err != nil { + return err + } + + // Parse the request data + req, err := utils.ParseDataRequestFromQuery(result) + if err != nil { + // If parsing fails, show raw output in verbose mode + if verbose { + utils.PrintError(fmt.Sprintf("Failed to parse request: %v", err)) + fmt.Println(result) + } + return fmt.Errorf("failed to parse request data: %w", err) + } + + // Display request information in a clean format + utils.PrintSection(fmt.Sprintf("Request %s", req.ID)) + fmt.Println() + + // Basic Information + fmt.Println("Basic Information:") + utils.PrintKeyValue(" Request ID", req.ID) + utils.PrintKeyValue(" State", req.State) + utils.PrintKeyValue(" Creator", req.Creator) + utils.PrintKeyValue(" Question", req.AncillaryData) + if req.YesNoQuestion { + utils.PrintKeyValue(" Type", "Yes/No Question") + } else { + utils.PrintKeyValue(" Type", "Numeric") + } + // Note: Timestamps/Deadlines are stored as time.Time and can't be parsed from query output + // To display them, the contract would need getter functions that return Unix timestamps + + // Proposal Information + fmt.Println() + fmt.Println("Proposal Information:") + if req.Proposer != "" { + utils.PrintKeyValue(" Proposer", req.Proposer) + utils.PrintKeyValue(" Proposed Value", req.ProposedValue) + utils.PrintKeyValue(" Proposer Bond", fmt.Sprintf("%d ugnot", req.ProposerBond)) + } else { + utils.PrintKeyValue(" Status", "No proposal yet") + } + + // Dispute Information + fmt.Println() + fmt.Println("Dispute Information:") + if req.Disputer != "" { + utils.PrintKeyValue(" Disputer", req.Disputer) + utils.PrintKeyValue(" Disputer Bond", fmt.Sprintf("%d ugnot", req.DisputerBond)) + } else { + utils.PrintKeyValue(" Status", "Not disputed") + } + + // Resolution Information + if req.State == "Resolved" { + fmt.Println() + fmt.Println("Resolution:") + utils.PrintKeyValue(" Winning Value", req.WinningValue) + } + + fmt.Println() + + return nil + }, + } + + return cmd +} + +// NewRequestRetrieveFundCmd retrieves fund from unfulfilled request +func NewRequestRetrieveFundCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "retrieve-fund ", + Short: "Retrieve reward from unfulfilled request", + Long: "Retrieve the reward from a request that was not fulfilled before the deadline", + Args: cobra.ExactArgs(1), + Example: ` goo request retrieve-fund 0000001`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Execute transaction + if err := executor.CallFunction("RequesterRetreiveFund", []string{requestID}, ""); err != nil { + return err + } + + utils.PrintSuccess("Fund retrieval transaction submitted!") + utils.PrintInfo(fmt.Sprintf("Request ID: %s", requestID)) + + return nil + }, + } + + return cmd +} diff --git a/packages/r/intermarch3/goo-cli/internal/commands/root.go b/packages/r/intermarch3/goo-cli/internal/commands/root.go new file mode 100644 index 0000000..6e34303 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/commands/root.go @@ -0,0 +1,38 @@ +package commands + +import ( + "github.com/spf13/cobra" +) + +var ( + keyOverride string + verbose bool +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "goo", + Short: "GOO Oracle CLI", + Long: `A command-line interface for interacting with the GOO Oracle on Gno.land`, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() error { + return rootCmd.Execute() +} + +func init() { + // Add global flags + rootCmd.PersistentFlags().StringVarP(&keyOverride, "key", "k", "", "Override the key name from config") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output") + + // Add all subcommands + rootCmd.AddCommand(NewConfigCmd()) + rootCmd.AddCommand(NewRequestCmd()) + rootCmd.AddCommand(NewProposeCmd()) + rootCmd.AddCommand(NewDisputeCmd()) + rootCmd.AddCommand(NewVoteCmd()) + rootCmd.AddCommand(NewQueryCmd()) + rootCmd.AddCommand(NewAdminCmd()) +} diff --git a/packages/r/intermarch3/goo-cli/internal/commands/vote.go b/packages/r/intermarch3/goo-cli/internal/commands/vote.go new file mode 100644 index 0000000..1b77659 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/commands/vote.go @@ -0,0 +1,186 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "goo-cli/internal/config" + "goo-cli/internal/gnokey" + "goo-cli/internal/utils" +) + +// NewVoteCmd creates the vote command +func NewVoteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "vote", + Short: "Manage voting on disputes", + Long: "Buy tokens, commit votes, reveal votes, and check balance", + } + + cmd.AddCommand(NewVoteBuyTokenCmd()) + cmd.AddCommand(NewVoteBalanceCmd()) + cmd.AddCommand(NewVoteCommitCmd()) + cmd.AddCommand(NewVoteRevealCmd()) + + return cmd +} + +// NewVoteBuyTokenCmd buys initial vote tokens +func NewVoteBuyTokenCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "buy-token", + Short: "Buy initial vote token", + Long: "Purchase the initial vote token required to participate in voting", + Example: ` goo vote buy-token`, + RunE: func(cmd *cobra.Command, args []string) error { + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Query the vote token price from contract + utils.PrintInfo("Querying vote token price from contract...") + price, err := executor.QueryInt64("GetVoteTokenPrice") + if err != nil { + return fmt.Errorf("failed to query vote token price: %w", err) + } + + utils.PrintInfo(fmt.Sprintf("Vote token price: %d ugnot", price)) + + // Execute transaction + sendAmount := fmt.Sprintf("%dugnot", price) + if err := executor.CallFunction("BuyInitialVoteToken", []string{}, sendAmount); err != nil { + return err + } + + utils.PrintSuccess("Vote token purchased successfully!") + utils.PrintInfo(fmt.Sprintf("Price paid: %d ugnot", price)) + + return nil + }, + } + + return cmd +} + +// NewVoteBalanceCmd checks vote token balance +func NewVoteBalanceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "balance", + Short: "Check vote token balance", + Long: "Query your current vote token balance (requires signing)", + Example: ` goo vote balance`, + RunE: func(cmd *cobra.Command, args []string) error { + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Call as transaction since it requires realm context + if err := executor.CallFunction("BalanceOfVoteToken", []string{}, ""); err != nil { + return err + } + + utils.PrintSuccess("Balance query executed successfully!") + + return nil + }, + } + + return cmd +} + +// NewVoteCommitCmd commits a vote +func NewVoteCommitCmd() *cobra.Command { + var salt string + + cmd := &cobra.Command{ + Use: "commit ", + Short: "Commit a vote on a dispute", + Long: "Submit a hashed vote during the voting period. The hash will be revealed later.", + Args: cobra.ExactArgs(2), + Example: ` goo vote commit 0000001 3500 + goo vote commit 0000001 3500 --salt my-random-salt`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + value := args[1] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Auto-generate salt if not provided + if salt == "" { + salt = utils.GenerateRandomSalt(32) + utils.PrintInfo(fmt.Sprintf("Auto-generated salt: %s", salt)) + } + + // Generate hash + hash := utils.GenerateVoteHash(value, salt) + + // Execute transaction + funcArgs := []string{requestID, hash} + if err := executor.CallFunction("VoteOnDispute", funcArgs, ""); err != nil { + return err + } + + // Save vote data locally + if err := gnokey.SaveVoteLocally(requestID, value, salt, hash); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to save vote locally: %v", err)) + } + + utils.PrintSuccess("Vote committed successfully!") + utils.PrintInfo(fmt.Sprintf("Request ID: %s", requestID)) + utils.PrintInfo(fmt.Sprintf("Value: %s", value)) + utils.PrintInfo(fmt.Sprintf("Hash: %s", hash)) + utils.PrintInfo("Vote data saved locally for reveal phase") + + return nil + }, + } + + cmd.Flags().StringVar(&salt, "salt", "", "Salt for vote hash (auto-generated if not provided)") + + return cmd +} + +// NewVoteRevealCmd reveals a committed vote +func NewVoteRevealCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "reveal ", + Short: "Reveal a committed vote", + Long: "Reveal your vote during the reveal period using locally stored vote data", + Args: cobra.ExactArgs(1), + Example: ` goo vote reveal 0000001`, + RunE: func(cmd *cobra.Command, args []string) error { + requestID := args[0] + + keyOverride, _ := cmd.Flags().GetString("key") + verbose, _ := cmd.Flags().GetBool("verbose") + cfg := config.LoadWithKeyOverride(keyOverride) + executor := gnokey.NewExecutor(cfg, verbose) + + // Load vote data from local storage + value, salt, err := gnokey.LoadVoteLocally(requestID) + if err != nil { + return fmt.Errorf("failed to load vote data: %w", err) + } + + // Execute transaction + funcArgs := []string{requestID, value, salt} + if err := executor.CallFunction("RevealVote", funcArgs, ""); err != nil { + return err + } + + utils.PrintSuccess("Vote revealed successfully!") + utils.PrintInfo(fmt.Sprintf("Request ID: %s", requestID)) + utils.PrintInfo(fmt.Sprintf("Value: %s", value)) + + return nil + }, + } + + return cmd +} diff --git a/packages/r/intermarch3/goo-cli/internal/config/config.go b/packages/r/intermarch3/goo-cli/internal/config/config.go new file mode 100644 index 0000000..a1eb5ae --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/config/config.go @@ -0,0 +1,151 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +// Config holds the CLI configuration +type Config struct { + KeyName string `yaml:"keyname" mapstructure:"keyname"` + RealmPath string `yaml:"realm_path" mapstructure:"realm_path"` + ChainID string `yaml:"chain_id" mapstructure:"chain_id"` + Remote string `yaml:"remote" mapstructure:"remote"` + GasFee string `yaml:"gas_fee" mapstructure:"gas_fee"` + GasWanted int64 `yaml:"gas_wanted" mapstructure:"gas_wanted"` + GoogleAPIKey string `yaml:"google_api_key" mapstructure:"google_api_key"` +} + +// DefaultConfig returns a config with default values +func DefaultConfig() *Config { + return &Config{ + KeyName: "mykey", + RealmPath: "gno.land/r/intermarch3/goo", + ChainID: "dev", + Remote: "tcp://127.0.0.1:26657", + GasFee: "1000000ugnot", + GasWanted: 20000000, + } +} + +// GetConfigPath returns the path to the config file +func GetConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".goo", "config.yaml"), nil +} + +// Load reads the configuration from file or returns defaults +func Load() *Config { + configPath, err := GetConfigPath() + if err != nil { + fmt.Printf("Warning: %v, using defaults\n", err) + return DefaultConfig() + } + + viper.SetConfigFile(configPath) + viper.SetConfigType("yaml") + + if err := viper.ReadInConfig(); err != nil { + // If config doesn't exist, return defaults + if os.IsNotExist(err) { + return DefaultConfig() + } + fmt.Printf("Warning: failed to read config: %v, using defaults\n", err) + return DefaultConfig() + } + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + fmt.Printf("Warning: failed to parse config: %v, using defaults\n", err) + return DefaultConfig() + } + + return &cfg +} + +// LoadWithKeyOverride loads config and overrides the key name if provided +func LoadWithKeyOverride(keyOverride string) *Config { + cfg := Load() + if keyOverride != "" { + cfg.KeyName = keyOverride + } + return cfg +} + +// Save writes the configuration to file +func Save(cfg *Config) error { + configPath, err := GetConfigPath() + if err != nil { + return err + } + + // Create directory if it doesn't exist + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Marshal to YAML + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Write to file + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// InitConfig creates a new config file with default values +func InitConfig() error { + configPath, err := GetConfigPath() + if err != nil { + return err + } + + // Check if config already exists + if _, err := os.Stat(configPath); err == nil { + return fmt.Errorf("config file already exists at %s", configPath) + } + + // Save default config + cfg := DefaultConfig() + if err := Save(cfg); err != nil { + return err + } + + fmt.Printf("✓ Config file created at %s\n", configPath) + fmt.Println("\nDefault configuration:") + fmt.Printf(" Key Name: %s\n", cfg.KeyName) + fmt.Printf(" Realm Path: %s\n", cfg.RealmPath) + fmt.Printf(" Chain ID: %s\n", cfg.ChainID) + fmt.Printf(" Remote: %s\n", cfg.Remote) + fmt.Printf(" Gas Fee: %s\n", cfg.GasFee) + fmt.Printf(" Gas Wanted: %d\n", cfg.GasWanted) + if cfg.GoogleAPIKey != "" { + fmt.Printf(" Google API Key: %s\n", cfg.GoogleAPIKey[:min(8, len(cfg.GoogleAPIKey))]+"...") + } else { + fmt.Printf(" Google API Key: (not configured)\n") + } + fmt.Println("\nEdit this file to customize your settings.") + + return nil +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/packages/r/intermarch3/goo-cli/internal/gnokey/executor.go b/packages/r/intermarch3/goo-cli/internal/gnokey/executor.go new file mode 100644 index 0000000..94e9f1a --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/gnokey/executor.go @@ -0,0 +1,279 @@ +package gnokey + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "goo-cli/internal/config" + "goo-cli/internal/utils" +) + +// TxExecutor handles execution of gnokey transactions +type TxExecutor struct { + KeyName string + RealmPath string + ChainID string + Remote string + GasFee string + GasWanted int64 + Verbose bool +} + +// NewExecutor creates a new TxExecutor from config +func NewExecutor(cfg *config.Config, verbose bool) *TxExecutor { + return &TxExecutor{ + KeyName: cfg.KeyName, + RealmPath: cfg.RealmPath, + ChainID: cfg.ChainID, + Remote: cfg.Remote, + GasFee: cfg.GasFee, + GasWanted: cfg.GasWanted, + Verbose: verbose, + } +} + +// CallFunction executes a function call (transaction) +func (e *TxExecutor) CallFunction(funcName string, args []string, sendCoins string) error { + // Build command arguments + cmdArgs := []string{ + "maketx", "call", + "--pkgpath", e.RealmPath, + "--func", funcName, + "--gas-fee", e.GasFee, + "--gas-wanted", fmt.Sprintf("%d", e.GasWanted), + "--broadcast", + "--chainid", e.ChainID, + "--remote", e.Remote, + } + + // Add function arguments + for _, arg := range args { + cmdArgs = append(cmdArgs, "--args", arg) + } + + // Add coins if specified + if sendCoins != "" { + cmdArgs = append(cmdArgs, "--send", sendCoins) + } + + // Add key name + cmdArgs = append(cmdArgs, e.KeyName) + + // Always print the command for transactions (user needs to see what they're signing) + fmt.Println("Executing:") + printCommand("gnokey", cmdArgs) + fmt.Println() + + // Execute the command with inherited stdin for interactive password input + cmd := exec.Command("gnokey", cmdArgs...) + cmd.Stdin = os.Stdin + + // Handle stdout and stderr based on verbose mode + var stdoutBuf, stderrBuf bytes.Buffer + if e.Verbose { + // In verbose mode, show full output + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } else { + // In non-verbose mode, capture both stdout and stderr + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + // Print password prompt since stderr is not shown + fmt.Print("Password: ") + } + + if err := cmd.Run(); err != nil { + // Print newline after password input in non-verbose mode + if !e.Verbose { + fmt.Println() + } + // If error occurred in non-verbose mode, parse the captured stderr + if !e.Verbose && stderrBuf.Len() > 0 { + // Create error from captured stderr and parse it for friendly message + return utils.ParseContractError(fmt.Errorf("%s", stderrBuf.String())) + } + // In verbose mode or if no stderr captured, return the error as-is + return err + } + + // Print newline after password input in non-verbose mode on success + if !e.Verbose { + fmt.Println() + } + + return nil +} + +// QueryFunction executes a query (read-only call) +func (e *TxExecutor) QueryFunction(funcName string, args []string) (string, error) { + // Build the query path with function call syntax + queryPath := fmt.Sprintf("%s.%s(", e.RealmPath, funcName) + if len(args) > 0 { + formattedArgs := formatArgs(args) + queryPath += strings.Join(formattedArgs, ",") + } + queryPath += ")" + + // Build command arguments + cmdArgs := []string{ + "query", "vm/qeval", + "--remote", e.Remote, + "--data", queryPath, + } + + // Print the command being executed only in verbose mode + if e.Verbose { + fmt.Println("Executing:") + printCommand("gnokey", cmdArgs) + fmt.Println() + } + + // Execute the command + cmd := exec.Command("gnokey", cmdArgs...) + output, err := cmd.CombinedOutput() + + // Print output only in verbose mode + if e.Verbose { + fmt.Println(string(output)) + } + + if err != nil { + return "", utils.ParseContractError(fmt.Errorf("query failed: %w", err)) + } + + return string(output), nil +} + +// formatArgs formats arguments for Gno function calls +func formatArgs(args []string) []string { + formatted := make([]string, len(args)) + for i, arg := range args { + formatted[i] = fmt.Sprintf("\"%s\"", arg) + } + return formatted +} + +// printCommand prints a command with proper quoting for display +func printCommand(name string, args []string) { + fmt.Print(name) + for _, arg := range args { + // Quote arguments that contain spaces or special characters + if strings.ContainsAny(arg, " \t\n\"'") { + fmt.Printf(" \"%s\"", strings.ReplaceAll(arg, "\"", "\\\"")) + } else { + fmt.Printf(" %s", arg) + } + } + fmt.Println() +} + +// QueryInt64 queries a function that returns an int64 value +func (e *TxExecutor) QueryInt64(funcName string) (int64, error) { + result, err := e.QueryFunction(funcName, []string{}) + if err != nil { + return 0, err + } + + // Parse the result to extract the int64 value + // The output format is like: "height: 0\ndata: (2000000 int64)\n" + var value int64 + lines := strings.Split(result, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "data:") { + // Extract the value from format: "data: (value type)" + line = strings.TrimPrefix(line, "data:") + line = strings.TrimSpace(line) + // Remove parentheses and split + line = strings.Trim(line, "()") + parts := strings.Fields(line) + if len(parts) >= 1 { + _, err = fmt.Sscanf(parts[0], "%d", &value) + if err == nil { + return value, nil + } + } + } + } + return 0, utils.ParseContractError(fmt.Errorf("failed to parse int64 from query result: %s", result)) +} + +// VoteData represents stored vote information +type VoteData struct { + RequestID string `json:"request_id"` + Value string `json:"value"` + Salt string `json:"salt"` + Hash string `json:"hash"` + Timestamp string `json:"timestamp"` +} + +// SaveVoteLocally saves vote data to local storage +func SaveVoteLocally(requestID, value, salt, hash string) error { + // Get home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + // Create votes directory + votesDir := fmt.Sprintf("%s/.goo/votes", homeDir) + if err := os.MkdirAll(votesDir, 0755); err != nil { + return fmt.Errorf("failed to create votes directory: %w", err) + } + + // Create vote data + voteData := VoteData{ + RequestID: requestID, + Value: value, + Salt: salt, + Hash: hash, + Timestamp: time.Now().Format(time.RFC3339), + } + + // Marshal to JSON + data, err := json.MarshalIndent(voteData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal vote data: %w", err) + } + + // Write to file + filePath := fmt.Sprintf("%s/%s.json", votesDir, requestID) + if err := os.WriteFile(filePath, data, 0644); err != nil { + return fmt.Errorf("failed to write vote file: %w", err) + } + + utils.PrintInfo(fmt.Sprintf("Vote data saved to: %s", filePath)) + return nil +} + +// LoadVoteLocally loads vote data from local storage +func LoadVoteLocally(requestID string) (value, salt string, err error) { + // Get home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("failed to get home directory: %w", err) + } + + // Read vote file + filePath := fmt.Sprintf("%s/.goo/votes/%s.json", homeDir, requestID) + data, err := os.ReadFile(filePath) + if err != nil { + return "", "", fmt.Errorf("failed to read vote file: %w (did you commit a vote for this request?)", err) + } + + // Unmarshal JSON + var voteData VoteData + if err := json.Unmarshal(data, &voteData); err != nil { + return "", "", fmt.Errorf("failed to parse vote data: %w", err) + } + + if voteData.Value == "" || voteData.Salt == "" { + return "", "", fmt.Errorf("vote data is incomplete") + } + + return voteData.Value, voteData.Salt, nil +} diff --git a/packages/r/intermarch3/goo-cli/internal/search_agent/gemini.go b/packages/r/intermarch3/goo-cli/internal/search_agent/gemini.go new file mode 100644 index 0000000..78b2314 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/search_agent/gemini.go @@ -0,0 +1,472 @@ +package search_agent + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// OracleResponse represents the structured output from the AI oracle +type OracleResponse struct { + Value string `json:"value"` + Sources []string `json:"sources"` + Why string `json:"why"` +} + +// GeminiClient wraps the Gemini API client for oracle queries +type GeminiClient struct { + apiKey string + apiURL string + httpClient *http.Client + verbose bool +} + +// API Request/Response structures for Gemini REST API +type geminiRequest struct { + Contents []geminiContent `json:"contents"` + Tools []geminiTool `json:"tools,omitempty"` + SystemInstruction *geminiContent `json:"system_instruction,omitempty"` +} + +type geminiContent struct { + Parts []geminiPart `json:"parts"` + Role string `json:"role,omitempty"` +} + +type geminiPart struct { + Text string `json:"text"` +} + +type geminiTool struct { + GoogleSearch *struct{} `json:"google_search,omitempty"` +} + +type geminiResponse struct { + Candidates []geminiCandidate `json:"candidates"` +} + +type geminiCandidate struct { + Content geminiContent `json:"content"` + GroundingMetadata *groundingMetadata `json:"groundingMetadata,omitempty"` +} + +type groundingMetadata struct { + GroundingChunks []groundingChunk `json:"groundingChunks,omitempty"` +} + +type groundingChunk struct { + Web *webChunk `json:"web,omitempty"` +} + +type webChunk struct { + URI string `json:"uri"` + Title string `json:"title"` +} + +// NewGeminiClient creates a new Gemini client for oracle queries using REST API +func NewGeminiClient(apiKey string, verbose bool) (*GeminiClient, error) { + if apiKey == "" { + return nil, fmt.Errorf("API key cannot be empty") + } + + // Use gemini-2.5-flash which supports google_search + apiURL := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" + + httpClient := &http.Client{ + Timeout: 60 * time.Second, + } + + return &GeminiClient{ + apiKey: apiKey, + apiURL: apiURL, + httpClient: httpClient, + verbose: verbose, + }, nil +} + +// Close closes the Gemini client connection +func (c *GeminiClient) Close() error { + // Nothing to close for HTTP client + return nil +} + +// QueryQuestion queries the AI with web search to answer the oracle question +func (c *GeminiClient) QueryQuestion(question string) (*OracleResponse, error) { + if question == "" { + return nil, fmt.Errorf("question cannot be empty") + } + + // Generate system prompt with current date + currentDate := time.Now().Format("January 2, 2006") + systemPrompt := getSystemPrompt(currentDate) + + if c.verbose { + fmt.Fprintf(os.Stderr, "\n🔍 Querying Gemini AI with Google Search...\n") + fmt.Fprintf(os.Stderr, " Model: gemini-2.5-flash\n") + fmt.Fprintf(os.Stderr, " Question: %s\n", question) + fmt.Fprintf(os.Stderr, " Date: %s\n\n", currentDate) + } + + // Prepare the request body + reqBody := geminiRequest{ + SystemInstruction: &geminiContent{ + Parts: []geminiPart{ + {Text: systemPrompt}, + }, + }, + Contents: []geminiContent{ + { + Parts: []geminiPart{ + {Text: question}, + }, + }, + }, + Tools: []geminiTool{ + { + GoogleSearch: &struct{}{}, + }, + }, + } + + // Marshal to JSON + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + if c.verbose { + fmt.Fprintf(os.Stderr, "📤 Request body:\n%s\n\n", string(jsonData)) + } + + // Create HTTP request + req, err := http.NewRequest("POST", c.apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-goog-api-key", c.apiKey) + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if c.verbose { + fmt.Fprintf(os.Stderr, "📥 Response status: %d\n", resp.StatusCode) + fmt.Fprintf(os.Stderr, "📥 Response body:\n%s\n\n", string(body)) + } + + // Check for errors + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Parse response + var geminiResp geminiResponse + if err := json.Unmarshal(body, &geminiResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Extract text from response + if len(geminiResp.Candidates) == 0 || len(geminiResp.Candidates[0].Content.Parts) == 0 { + return nil, fmt.Errorf("no response from Gemini") + } + + responseText := geminiResp.Candidates[0].Content.Parts[0].Text + + if c.verbose { + fmt.Fprintf(os.Stderr, "📝 Response text:\n%s\n\n", responseText) + } + + // Try to parse as JSON + oracleResp, err := parseJSONResponse(responseText) + if err == nil { + // Successfully parsed JSON + if c.verbose { + fmt.Fprintf(os.Stderr, "✓ Parsed JSON response\n") + fmt.Fprintf(os.Stderr, " Sources in JSON: %d\n", len(oracleResp.Sources)) + } + + // If sources are empty in JSON, try to extract from grounding metadata + if len(oracleResp.Sources) == 0 { + metadataSources := extractSourcesFromMetadata(&geminiResp) + if c.verbose { + fmt.Fprintf(os.Stderr, " Sources from metadata: %d\n", len(metadataSources)) + } + oracleResp.Sources = metadataSources + } + + // Validate and filter sources + if len(oracleResp.Sources) > 0 { + oracleResp.Sources = filterAndValidateSources(oracleResp.Sources, 5, c.verbose) + } + + return oracleResp, nil + } + + // Not JSON - treat as plain text response + if c.verbose { + fmt.Fprintf(os.Stderr, "⚠ Response is not JSON, extracting from plain text...\n\n") + } + + // Extract sources from grounding metadata + sources := extractSourcesFromMetadata(&geminiResp) + if len(sources) > 0 { + sources = filterAndValidateSources(sources, 5, c.verbose) + } + + // Extract value from first sentence or line + value := responseText + if idx := strings.Index(responseText, "."); idx != -1 && idx < 200 { + value = strings.TrimSpace(responseText[:idx]) + } else if lines := strings.Split(responseText, "\n"); len(lines) > 0 { + value = strings.TrimSpace(lines[0]) + if len(value) > 200 { + value = value[:200] + } + } + + return &OracleResponse{ + Value: value, + Sources: sources, + Why: strings.TrimSpace(responseText), + }, nil +} + +// getSystemPrompt generates the system prompt with current date +func getSystemPrompt(currentDate string) string { + return fmt.Sprintf(`You are an AI research agent designed to answer questions for an optimistic oracle proposer. + +CRITICAL CONTEXT: +- TODAY'S DATE: %s +- You MUST use this date to determine if questions are about the past or future. + +Your job is to perform real web research, find reliable factual information, extract a final numeric or factual value, and return: + +1. THE PROPOSED VALUE (the answer to the question) +2. THE SOURCES (real URLs only, no hallucinated links) +3. THE JUSTIFICATION (short reasoning + quotes from sources) + +TEMPORAL REASONING (VERY IMPORTANT): +- Determine if the FACTUAL INFORMATION being asked about exists NOW or only in the future. +- Questions about PAST EVENTS are answerable even if they mention future dates (e.g., "Who was elected for the 2025-2029 term?" - the election happened in the past, so this is answerable) +- Questions about FUTURE EVENTS that haven't happened yet are NOT answerable (e.g., "Who will win the 2026 election?" - this hasn't happened yet) + +KEY DISTINCTION: +- "Who is/was elected mayor for 2025-2029?" → PAST/PRESENT (election happened, result exists) +- "What was the price on [past date]?" → PAST (data exists) +- "Who will be elected in 2026?" → FUTURE (event hasn't happened, no data exists) +- "What will the price be on [future date]?" → FUTURE (data doesn't exist yet) + +REQUIREMENTS: +- FIRST, determine if the FACTUAL DATA being asked about exists as of %s. +- Ask yourself: "Has the event/data point this question refers to already occurred/been determined?" +- If the answer is NO (future event, no data exists yet), return FUTURE_QUESTION_ERROR: + { + "value": "FUTURE_QUESTION_ERROR", + "sources": [], + "why": "This question asks about a future event that hasn't occurred yet as of %s. Oracle cannot answer questions about the future as no factual data exists yet." + } +- If the answer is YES (past/present event, data exists), proceed with research. +- YOU HAVE ACCESS TO GOOGLE SEARCH. Use it to find current, factual information. +- Extract REAL URLs from your search results. The "sources" field MUST contain actual URLs you found through Google Search. +- Never fabricate or invent URLs. Only include URLs that appear in your search results. +- When reading sources from search results, extract exact quotes that support your answer. +- If multiple sources disagree, explain the discrepancy and choose the most credible one. +- If no definitive answer exists for a PAST question after searching, return "INSUFFICIENT DATA". +- The "value" must be as precise and unambiguous as possible. + +CRITICAL VALUE FORMAT RULES: +For NUMERIC questions, the "value" field must contain ONLY a pure number: + - Format: integer or decimal with period (.) as decimal separator + - Examples of CORRECT formats: "3874" or "3874.50" or "-42" or "0.5" + - Examples of WRONG formats: "$3,874" or "3.874 USD" or "approximately 3874" or "three thousand" + - NO currency symbols ($, €, £, ¥) + - NO thousand separators (no commas) + - NO units or text (USD, dollars, approximately, etc.) + - NO leading/trailing text, just the raw number + +For YES/NO questions, the "value" field must contain ONLY: + - "Yes" or "No" (exactly these words, nothing else) + - NOT "yes, because..." or "The answer is yes" - just "Yes" or "No" + +The "value" field is parsed programmatically. Any extra text will cause an error. +Put explanations in the "why" field, not in "value". + +Output must ALWAYS follow the JSON template: + +{ + "value": "...", + "sources": ["url1", "url2", ...], + "why": "explanation with quotes" +} + +CRITICAL OUTPUT FORMAT: +- Your response MUST be ONLY valid JSON. +- Do NOT include any text before or after the JSON object. +- Do NOT write explanatory sentences or commentary. +- ONLY output the JSON object starting with { and ending with }. +- The very first character of your response must be { and the very last must be }. +- Example format: +{"value": "Yes", "sources": ["https://example.com"], "why": "Based on..."}`, currentDate, currentDate, currentDate) +} + +// parseJSONResponse attempts to parse JSON from various response formats +func parseJSONResponse(rawResponse string) (*OracleResponse, error) { + var response OracleResponse + + // Try direct parse + err := json.Unmarshal([]byte(rawResponse), &response) + if err == nil { + return &response, nil + } + + // Try extracting JSON from markdown code block + if strings.Contains(rawResponse, "```json") { + start := strings.Index(rawResponse, "```json") + 7 + remaining := rawResponse[start:] + end := strings.Index(remaining, "```") + if end != -1 { + jsonStr := strings.TrimSpace(remaining[:end]) + err = json.Unmarshal([]byte(jsonStr), &response) + if err == nil { + return &response, nil + } + } + } + + // Try extracting JSON boundaries + start := strings.Index(rawResponse, "{") + end := strings.LastIndex(rawResponse, "}") + if start != -1 && end > start { + jsonStr := rawResponse[start : end+1] + err = json.Unmarshal([]byte(jsonStr), &response) + if err == nil { + return &response, nil + } + } + + return nil, fmt.Errorf("could not extract valid JSON from response") +} + +// extractSourcesFromMetadata extracts URLs from grounding metadata +func extractSourcesFromMetadata(resp *geminiResponse) []string { + sources := make([]string, 0) + + if len(resp.Candidates) == 0 { + return sources + } + + candidate := resp.Candidates[0] + if candidate.GroundingMetadata == nil { + return sources + } + + metadata := candidate.GroundingMetadata + if metadata.GroundingChunks == nil { + return sources + } + + for _, chunk := range metadata.GroundingChunks { + if chunk.Web != nil && chunk.Web.URI != "" { + sources = append(sources, chunk.Web.URI) + } + } + + return sources +} + +// validateURL checks if a URL is accessible (doesn't return 404 or error) +// Reproduces the exact behavior from the Python PoC +func validateURL(url string, timeout time.Duration) bool { + // Simple HTTP client with timeout + client := &http.Client{ + Timeout: timeout, + } + + // Create GET request with User-Agent header + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false + } + + // Set User-Agent to avoid being blocked (same as Python PoC) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + // Send request + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + // Check if status is 200 OK (same as Python PoC) + return resp.StatusCode == 200 +} + +// filterAndValidateSources validates URLs and limits to maxSources +func filterAndValidateSources(sources []string, maxSources int, verbose bool) []string { + if len(sources) == 0 { + return []string{} + } + + validated := make([]string, 0, maxSources) + + if verbose { + fmt.Fprintf(os.Stderr, "\n🔍 Validating %d sources (checking for 404 errors)...\n", len(sources)) + } + + for i, url := range sources { + if len(validated) >= maxSources { + break + } + + if verbose { + fmt.Fprintf(os.Stderr, " [%d/%d] Checking: %s\n", i+1, min(len(sources), maxSources), url) + } + + if validateURL(url, 5*time.Second) { + validated = append(validated, url) + if verbose { + fmt.Fprintf(os.Stderr, " ✓ Valid\n") + } + } else { + if verbose { + fmt.Fprintf(os.Stderr, " ✗ Error (404 or unreachable)\n") + } + } + } + + if verbose { + fmt.Fprintf(os.Stderr, "✓ %d valid sources found\n\n", len(validated)) + } else if len(sources) > 0 { + // In non-verbose mode, show a summary + fmt.Fprintf(os.Stderr, "✓ %d valid sources (out of %d found)\n", len(validated), len(sources)) + } + + return validated +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + diff --git a/packages/r/intermarch3/goo-cli/internal/utils/errors.go b/packages/r/intermarch3/goo-cli/internal/utils/errors.go new file mode 100644 index 0000000..c8ee02a --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/utils/errors.go @@ -0,0 +1,114 @@ +package utils + +import ( + "fmt" + "strings" +) + +// ContractError represents a user-friendly error message +type ContractError struct { + Original string + Friendly string +} + +// ParseContractError converts contract error messages to user-friendly messages +func ParseContractError(err error) error { + if err == nil { + return nil + } + + errMsg := err.Error() + + // Common contract error patterns with user-friendly messages + errorMappings := map[string]string{ + // Request errors + "Ancillary data cannot be empty": "❌ Question/ancillary data is required", + "Deadline must be at least 24 hours in the future": "❌ Deadline must be at least 24 hours from now", + "Incorrect reward amount sent": "❌ Incorrect reward amount (check with 'goo query params')", + "Request with this ID does not exist": "❌ Request not found - invalid request ID", + "Request is not in 'Requested' state": "❌ Request is not available for proposals (may be already proposed, disputed, or resolved)", + "Deadline for proposal has passed": "❌ Proposal deadline has passed", + "Request has not been proposed yet": "❌ No proposal submitted for this request yet", + "Request is already resolved": "❌ Request is already resolved", + "cannot retreive fund as requests fulfilled": "❌ Cannot retrieve funds - request has been fulfilled", + "Only the creator of the request can retrieve the fund": "❌ Only the request creator can retrieve the fund", + "Cannot retrieve fund before the deadline": "❌ Cannot retrieve fund - deadline not reached yet", + + // Proposal errors + "Proposed value must be 0 or 1 for yes/no questions": "❌ For yes/no questions, value must be 0 (no) or 1 (yes)", + "Incorrect bond amount sent": "❌ Incorrect bond amount (check with 'goo query params')", + "Resolution period has not ended yet": "❌ Cannot resolve yet - resolution period still active", + "Request is in 'Disputed' state": "❌ Cannot resolve - request is disputed", + "Proposer cannot dispute their own proposal": "❌ You cannot dispute your own proposal", + "Request is not in 'Proposed' state": "❌ Request is not in proposed state (may be already disputed or resolved)", + "Dispute period has ended": "❌ Dispute period has ended", + "Dispute for this request already exists": "❌ This request is already disputed", + "Dispute is already resolved": "❌ Dispute is already resolved", + "Dispute period has not ended yet": "❌ Dispute period has not ended yet", + "Request is not resolved": "❌ Request is not resolved yet - cannot get result", + + // Vote errors + "You already have a vote token": "❌ You already own a vote token", + "Must send exactly": "❌ Incorrect vote token price (check with 'goo query params')", + "Proposer and Disputer cannot vote in this dispute": "❌ Proposers and disputers cannot vote on their own disputes", + "Voter has already voted in this dispute": "❌ You have already voted in this dispute", + "You need at least 1 vote token to vote": "❌ You need to buy a vote token first ('goo vote buy-token')", + "Vote period has ended": "❌ Voting period has ended", + "Vote period has not ended yet": "❌ Cannot reveal yet - voting period still active", + "Reveal period has ended": "❌ Reveal period has ended", + "Voter did not participate in this dispute": "❌ You did not vote in this dispute", + "Vote already revealed": "❌ Vote already revealed", + "Hash does not match the revealed value and salt": "❌ Hash mismatch - value or salt incorrect (check ~/.goo/votes/)", + "Dispute with this ID does not exist": "❌ Dispute not found - invalid dispute ID", + "Dispute is resolved": "❌ Dispute is already resolved", + + // Admin errors + "Only the admin can": "❌ Admin privileges required", + "Only admin can": "❌ Admin privileges required", + + // General errors + "missing realm argument": "❌ Internal error - realm context required", + "query failed": "❌ Query failed", + "failed to query": "❌ Failed to query contract", + } + + // Check for each error pattern + for pattern, friendlyMsg := range errorMappings { + if strings.Contains(errMsg, pattern) { + return fmt.Errorf("%s", friendlyMsg) + } + } + + // If no pattern matches, check if it's a contract error and clean it up + if strings.Contains(errMsg, "Error =--") { + // Extract the actual error message from contract output + if idx := strings.Index(errMsg, "error:"); idx != -1 { + // Find the end of the error message + rest := errMsg[idx+7:] // Skip "error: " + if endIdx := strings.Index(rest, "\n"); endIdx != -1 { + cleanMsg := strings.TrimSpace(rest[:endIdx]) + return fmt.Errorf("❌ Contract error: %s", cleanMsg) + } + } + if idx := strings.Index(errMsg, "Data:"); idx != -1 { + rest := errMsg[idx+5:] + if endIdx := strings.Index(rest, "\n"); endIdx != -1 { + cleanMsg := strings.TrimSpace(rest[:endIdx]) + return fmt.Errorf("❌ %s", cleanMsg) + } + } + } + + // Return original error if no pattern matches + return err +} + +// HandleError prints a user-friendly error message +func HandleError(err error) { + if err == nil { + return + } + + friendlyErr := ParseContractError(err) + PrintError(friendlyErr.Error()) +} diff --git a/packages/r/intermarch3/goo-cli/internal/utils/format.go b/packages/r/intermarch3/goo-cli/internal/utils/format.go new file mode 100644 index 0000000..2ea795c --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/utils/format.go @@ -0,0 +1,474 @@ +package utils + +import ( + "fmt" + "strings" + "time" +) + +// FormatUgnot formats ugnot amount for display +func FormatUgnot(amount int64) string { + return fmt.Sprintf("%d ugnot", amount) +} + +// FormatBool formats boolean for Gno function calls +func FormatBool(b bool) string { + if b { + return "true" + } + return "false" +} + +// FormatDuration formats a duration in a human-readable way +func FormatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) + } + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + return fmt.Sprintf("%dd%dh", days, hours) +} + +// FormatTimestamp formats a Unix timestamp +func FormatTimestamp(ts int64) string { + t := time.Unix(ts, 0) + return t.Format("2006-01-02 15:04:05 MST") +} + +// FormatTimeUntil formats time remaining until a timestamp +func FormatTimeUntil(ts int64) string { + t := time.Unix(ts, 0) + duration := time.Until(t) + if duration < 0 { + return "expired" + } + return FormatDuration(duration) +} + +// TruncateString truncates a string to maxLen with ellipsis +func TruncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +// FormatAddress formats a Gno address for display +func FormatAddress(addr string) string { + if len(addr) <= 16 { + return addr + } + return addr[:8] + "..." + addr[len(addr)-6:] +} + +// PrintKeyValue prints a key-value pair with proper alignment +func PrintKeyValue(key string, value interface{}) { + fmt.Printf(" %-20s %v\n", key+":", value) +} + +// PrintSection prints a section header +func PrintSection(title string) { + fmt.Println() + fmt.Println(strings.Repeat("=", 60)) + fmt.Printf(" %s\n", strings.ToUpper(title)) + fmt.Println(strings.Repeat("=", 60)) +} + +// PrintSuccess prints a success message +func PrintSuccess(message string) { + fmt.Printf("✓ %s\n", message) +} + +// PrintError prints an error message +func PrintError(message string) { + fmt.Printf("✗ %s\n", message) +} + +// PrintWarning prints a warning message +func PrintWarning(message string) { + fmt.Printf("⚠ %s\n", message) +} + +// PrintInfo prints an info message +func PrintInfo(message string) { + fmt.Printf("ℹ %s\n", message) +} + +// ParseStringArrayFromQuery parses a string array from gnokey query output +// Input format: "height: 0\ndata: (slice[(\"0000001\" string),(\"0000002\" string)] []string)\n" +// Returns: ["0000001", "0000002"] +func ParseStringArrayFromQuery(output string) ([]string, error) { + var result []string + + // Find the data line + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "data:") { + // Extract content between "slice[" and "]" + start := strings.Index(line, "slice[") + if start == -1 { + // Empty array case: (slice[] []string) + return result, nil + } + start += 6 // Move past "slice[" + + end := strings.Index(line[start:], "]") + if end == -1 { + return nil, fmt.Errorf("failed to parse array: missing closing bracket") + } + + content := line[start : start+end] + if content == "" { + return result, nil + } + + // Split by "),(" to get individual items + items := strings.Split(content, "),(") + for _, item := range items { + // Clean up the item: remove quotes and type annotation + // Format: ("0000001" string) or "0000001" string + item = strings.TrimPrefix(item, "(") + item = strings.TrimSuffix(item, ")") + item = strings.TrimSpace(item) + + // Extract the string value between quotes + if idx := strings.Index(item, "\""); idx != -1 { + endIdx := strings.Index(item[idx+1:], "\"") + if endIdx != -1 { + value := item[idx+1 : idx+1+endIdx] + result = append(result, value) + } + } + } + + return result, nil + } + } + + return nil, fmt.Errorf("no data field found in query output") +} + +// ParseStringFromQuery parses a string value from gnokey query output +// Input format: "height: 0\ndata: (\"Requested\" string)\n" +// Returns: "Requested" +func ParseStringFromQuery(output string) (string, error) { + // Find the data line + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "data:") { + // Extract the string value between quotes + line = strings.TrimPrefix(line, "data:") + line = strings.TrimSpace(line) + + // Remove parentheses and type annotation + line = strings.Trim(line, "()") + + // Extract string between quotes + if idx := strings.Index(line, "\""); idx != -1 { + endIdx := strings.Index(line[idx+1:], "\"") + if endIdx != -1 { + return line[idx+1 : idx+1+endIdx], nil + } + } + + return "", fmt.Errorf("failed to parse string from: %s", line) + } + } + + return "", fmt.Errorf("no data field found in query output") +} + +// DataRequest represents a parsed request from the contract +type DataRequest struct { + ID string + Creator string + Timestamp string + AncillaryData string + YesNoQuestion bool + ProposedValue int64 + Proposer string + ProposerBond int64 + Disputer string + DisputerBond int64 + ResolutionTime string + WinningValue int64 + State string + Deadline string +} + +// ParseDataRequestFromQuery parses a DataRequest struct from gnokey query output +// Input format: struct{("0000001" string),(address),(time.Time),("question" string),(true bool)...} +func ParseDataRequestFromQuery(output string) (*DataRequest, error) { + // Find the data line + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "data:") { + // Extract struct content + start := strings.Index(line, "struct{") + if start == -1 { + return nil, fmt.Errorf("no struct found in output") + } + start += 7 // Move past "struct{" + + end := strings.LastIndex(line, "}") + if end == -1 { + return nil, fmt.Errorf("struct not closed") + } + + content := line[start:end] + + // Split by "),(" to get individual fields + fields := splitStructFields(content) + + if len(fields) < 14 { + return nil, fmt.Errorf("expected 14 fields, got %d", len(fields)) + } + + req := &DataRequest{ + ID: extractStringValue(fields[0]), + Creator: extractAddressValue(fields[1]), + Timestamp: extractTimeValue(fields[2]), + AncillaryData: extractStringValue(fields[3]), + YesNoQuestion: extractBoolValue(fields[4]), + ProposedValue: extractInt64Value(fields[5]), + Proposer: extractAddressValue(fields[6]), + ProposerBond: extractInt64Value(fields[7]), + Disputer: extractAddressValue(fields[8]), + DisputerBond: extractInt64Value(fields[9]), + ResolutionTime: extractTimeValue(fields[10]), + WinningValue: extractInt64Value(fields[11]), + State: extractStringValue(fields[12]), + Deadline: extractTimeValue(fields[13]), + } + + return req, nil + } + } + + return nil, fmt.Errorf("no data field found in query output") +} + +// splitStructFields splits struct fields handling nested parentheses +func splitStructFields(content string) []string { + var fields []string + var current strings.Builder + depth := 0 + + for i := 0; i < len(content); i++ { + c := content[i] + + if c == '(' { + depth++ + current.WriteByte(c) + } else if c == ')' { + depth-- + current.WriteByte(c) + + // If we're back to depth 0 and next char is comma, this is end of field + if depth == 0 && i+1 < len(content) && content[i+1] == ',' { + fields = append(fields, current.String()) + current.Reset() + i++ // Skip the comma + } + } else { + current.WriteByte(c) + } + } + + // Add last field + if current.Len() > 0 { + fields = append(fields, current.String()) + } + + return fields +} + +// extractStringValue extracts string from format: ("value" string) +func extractStringValue(field string) string { + field = strings.TrimSpace(field) + field = strings.TrimPrefix(field, "(") + field = strings.TrimSuffix(field, ")") + + if idx := strings.Index(field, "\""); idx != -1 { + endIdx := strings.Index(field[idx+1:], "\"") + if endIdx != -1 { + return field[idx+1 : idx+1+endIdx] + } + } + return "" +} + +// extractAddressValue extracts address from format: (address) or ("g1..." .uverse.address) +func extractAddressValue(field string) string { + field = strings.TrimSpace(field) + field = strings.TrimPrefix(field, "(") + field = strings.TrimSuffix(field, ")") + + // Check if it's a quoted address + if idx := strings.Index(field, "\""); idx != -1 { + endIdx := strings.Index(field[idx+1:], "\"") + if endIdx != -1 { + return field[idx+1 : idx+1+endIdx] + } + } + + // Check if it's empty address + if strings.Contains(field, ".uverse.address") && !strings.Contains(field, "g1") { + return "" + } + + // Extract g1 address if present + if idx := strings.Index(field, "g1"); idx != -1 { + // Find the end (space or quote) + addr := field[idx:] + if spaceIdx := strings.Index(addr, " "); spaceIdx != -1 { + addr = addr[:spaceIdx] + } + if quoteIdx := strings.Index(addr, "\""); quoteIdx != -1 { + addr = addr[:quoteIdx] + } + return strings.TrimSpace(addr) + } + + return "" +} + +// extractBoolValue extracts bool from format: (true bool) +func extractBoolValue(field string) bool { + return strings.Contains(field, "true") +} + +// extractInt64Value extracts int64 from format: (123 int64) +func extractInt64Value(field string) int64 { + field = strings.TrimSpace(field) + field = strings.TrimPrefix(field, "(") + + // Extract number before space or closing paren + var numStr string + for _, c := range field { + if c >= '0' && c <= '9' || c == '-' { + numStr += string(c) + } else { + break + } + } + + var val int64 + fmt.Sscanf(numStr, "%d", &val) + return val +} + +// extractTimeValue extracts time reference - just return placeholder for refs +func extractTimeValue(field string) string { + // Time is represented as ref(...) in the output + // We can't parse the actual time from this format + if strings.Contains(field, "ref(") { + return "N/A" + } + return "" +} + +// Dispute represents a parsed dispute from the contract +type Dispute struct { + RequestID string + Votes int // Number of votes (we can't parse the full Vote slice) + NbResolvedVotes int64 + IsResolved bool + WinningValue int64 + EndTime string + EndRevealTime string +} + +// ParseDisputeFromQuery parses a Dispute struct from gnokey query output +// Input format: struct{("0000002" string),(slice[...] []Vote),(0 int64),(...),(...),(ref(...))...} +func ParseDisputeFromQuery(output string) (*Dispute, error) { + // Find the data line + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "data:") { + // Extract struct content + start := strings.Index(line, "struct{") + if start == -1 { + return nil, fmt.Errorf("no struct found in output") + } + start += 7 // Move past "struct{" + + end := strings.LastIndex(line, "}") + if end == -1 { + return nil, fmt.Errorf("struct not closed") + } + + content := line[start:end] + + // Split by "),(" to get individual fields + fields := splitStructFields(content) + + if len(fields) < 8 { + return nil, fmt.Errorf("expected 8 fields, got %d", len(fields)) + } + + // Count votes in the slice + votesCount := countVotesInSlice(fields[1]) + + dispute := &Dispute{ + RequestID: extractStringValue(fields[0]), + Votes: votesCount, + NbResolvedVotes: extractInt64Value(fields[2]), + // fields[3] is *avl.Tree (Voters) - skip + IsResolved: extractBoolValue(fields[4]), + WinningValue: extractInt64Value(fields[5]), + EndTime: extractTimeValue(fields[6]), + EndRevealTime: extractTimeValue(fields[7]), + } + + return dispute, nil + } + } + + return nil, fmt.Errorf("no data field found in query output") +} + +// countVotesInSlice counts the number of votes in a slice field +// Format: (slice[ref(...),ref(...)] []Vote) +func countVotesInSlice(field string) int { + // Look for "slice[" and count refs + if !strings.Contains(field, "slice[") { + return 0 + } + + // Extract content between slice[ and ] + start := strings.Index(field, "slice[") + if start == -1 { + return 0 + } + start += 6 + + end := strings.Index(field[start:], "]") + if end == -1 { + return 0 + } + + content := field[start : start+end] + if content == "" { + return 0 + } + + // Count commas + 1 to get number of items + count := 1 + for _, c := range content { + if c == ',' { + count++ + } + } + + return count +} diff --git a/packages/r/intermarch3/goo-cli/internal/utils/hash.go b/packages/r/intermarch3/goo-cli/internal/utils/hash.go new file mode 100644 index 0000000..6f639e7 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/utils/hash.go @@ -0,0 +1,32 @@ +package utils + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +// GenerateVoteHash generates SHA256 hash from value and salt +func GenerateVoteHash(value, salt string) string { + data := value + salt + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} + +// GenerateRandomSalt generates a random salt of specified length +func GenerateRandomSalt(length int) string { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + // Fallback to timestamp-based salt if crypto/rand fails + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(bytes) +} + +// VerifyVoteHash verifies that a hash matches the value and salt +func VerifyVoteHash(hash, value, salt string) bool { + expectedHash := GenerateVoteHash(value, salt) + return hash == expectedHash +} diff --git a/packages/r/intermarch3/goo-cli/internal/utils/time.go b/packages/r/intermarch3/goo-cli/internal/utils/time.go new file mode 100644 index 0000000..388ad6a --- /dev/null +++ b/packages/r/intermarch3/goo-cli/internal/utils/time.go @@ -0,0 +1,40 @@ +package utils + +import ( + "fmt" + "time" +) + +// ParseDeadline parses a deadline string in RFC3339 format +func ParseDeadline(deadline string) (time.Time, error) { + t, err := time.Parse(time.RFC3339, deadline) + if err != nil { + return time.Time{}, fmt.Errorf("invalid deadline format (expected RFC3339): %w", err) + } + return t, nil +} + +// FormatTimeRFC3339 formats a time in RFC3339 format +func FormatTimeRFC3339(t time.Time) string { + return t.Format(time.RFC3339) +} + +// GetCurrentTimestamp returns the current Unix timestamp +func GetCurrentTimestamp() int64 { + return time.Now().Unix() +} + +// IsExpired checks if a timestamp is in the past +func IsExpired(ts int64) bool { + return time.Now().Unix() > ts +} + +// TimeFromUnix converts Unix timestamp to time.Time +func TimeFromUnix(ts int64) time.Time { + return time.Unix(ts, 0) +} + +// DurationFromSeconds converts seconds to time.Duration +func DurationFromSeconds(seconds int64) time.Duration { + return time.Duration(seconds) * time.Second +} diff --git a/packages/r/intermarch3/goo-cli/pkg/types/oracle.go b/packages/r/intermarch3/goo-cli/pkg/types/oracle.go new file mode 100644 index 0000000..96fbb80 --- /dev/null +++ b/packages/r/intermarch3/goo-cli/pkg/types/oracle.go @@ -0,0 +1,74 @@ +package types + +import "time" + +// RequestState represents the state of a request +type RequestState int + +const ( + StateRequested RequestState = iota + StateProposed + StateDisputed + StateResolved +) + +func (s RequestState) String() string { + switch s { + case StateRequested: + return "Requested" + case StateProposed: + return "Proposed" + case StateDisputed: + return "Disputed" + case StateResolved: + return "Resolved" + default: + return "Unknown" + } +} + +// Request represents a data request +type Request struct { + ID string + Requester string + AncillaryData string + YesNoQuestion bool + ProposedValue int64 + Deadline time.Time + ResolutionTime time.Time + State RequestState + Proposer string + RequesterReward int64 +} + +// Dispute represents a dispute on a request +type Dispute struct { + RequestID string + Disputer string + DisputeInitiatedAt time.Time + VoteEndTime time.Time + RevealEndTime time.Time + TotalVotes int64 + VotesFor int64 + VotesAgainst int64 + Resolved bool +} + +// VoteData represents a vote commitment stored locally +type VoteData struct { + RequestID string `json:"request_id"` + Value int64 `json:"value"` + Salt string `json:"salt"` + Hash string `json:"hash"` + Timestamp int64 `json:"timestamp"` +} + +// OracleParams represents oracle parameters +type OracleParams struct { + Bond int64 + ResolutionTime int64 + RequesterReward int64 + DisputeDuration int64 + RevealDuration int64 + VoteTokenPrice int64 +} diff --git a/packages/r/intermarch3/goo/README.md b/packages/r/intermarch3/goo/README.md new file mode 100644 index 0000000..eef7a66 --- /dev/null +++ b/packages/r/intermarch3/goo/README.md @@ -0,0 +1,151 @@ +# Gno Optimistic Oracle (GOO) + +An Optimistic Oracle (GOO) built on Gno.land. This system is designed to bring external data onto the blockchain by leveraging game-theoretic incentives. It assumes data is correct unless disputed, hence the term "optimistic." +This implementation is inspired by the [UMA Optimistic Oracle](https://uma.xyz/) but adapted for the Gno ecosystem. + +## Table of Contents +- [Core Concepts](#core-concepts) +- [How It Works: The Lifecycle of a Data Request](#how-it-works-the-lifecycle-of-a-data-request) +- [Architecture](#architecture) +- [User Roles](#user-roles) +- [Tokenomics](#tokenomics) +- [Usage Example](#usage-example) +- [Developer](#developer) + +## Core Concepts + +The Gno Optimistic Oracle operates on the principle that data proposed to the oracle is assumed to be true. A bond is required for any new proposition. This proposition enters a "liveness" period where anyone can dispute it by posting an equal bond. + +- **Happy Path**: If no one disputes the data within the liveness period, it is considered resolved and accepted as truth. The proposer's bond is returned along with a reward. +- **Unhappy Path (Dispute)**: If the data is disputed, OOT (Optimistic Oracle Token) Holders can vote on the correct outcome. This is handled by the `court.gno` contract. Token holders vote, and the outcome is decided by the total token weight backing each value. The winner's bond is returned, and they receive a portion of the loser's slashed bond. +See [Tokenomics](#tokenomics) to understand how to get an OOT. + +## How It Works: The Lifecycle of a Data Request + +The entire process, from requesting data to its final resolution, follows a clear, multi-step path. + +### 1. Data Request (`RequestData`) +A user or a contract initiates a request for data by calling `RequestData`. +- **Ancillary Data**: A clear, human-readable question (e.g., "What was the price of ETH/USD at block X?"). +- **Type**: The request can be a `Yes/No` question (represented by 0 and 1) or a `Numeric` value. +- **Reward**: The requester must lock a `RequesterReward` in GNOT to incentivize a proposer to provide the data. +- **Deadline**: The requester sets a deadline by which the data must be proposed, otherwise they can retrieve their locked reward. + +### 2. Value Proposal (`ProposeValue`) +A **Proposer** provides an answer to the request. +- They call `ProposeValue` with the proposed answer. +- They must post a `Bond` in GNOT, which is held in escrow. +- This action starts the **Resolution Time**, a liveness window during which the proposal can be disputed. + +### 3. The Liveness Period +Once a value is proposed, a countdown begins. During this period, anyone can challenge the proposed value. + +- **If Undisputed**: If the `ResolutionTime` expires without any disputes, the request is considered final. Anyone can call `ResolveRequest`. The `ProposedValue` becomes the `WinningValue`. The Proposer gets their bond back, plus the `RequesterReward`. +- **If Disputed**: If another user believes the proposed value is incorrect, they can challenge it. + +### 4. Dispute (`DisputeData`) +A **Disputer** can challenge the Proposer's value. +- They must call `DisputeData` before the `ResolutionTime` ends. +- They must also post a `Bond` equal to the Proposer's bond. +- This action pauses the request's resolution and initiates a formal dispute, handled by the `court.gno` contract. + +### 5. Voting (`VoteOnDispute`) +The dispute is now open for voting by all OOT holders. The system uses a **commit-reveal scheme** to prevent vote-copying. + +- **Commit Phase**: During the `DisputeDuration`, voters submit a hash of their vote (`SHA256(value + salt)`) by calling `VoteOnDispute`. They must also pay a small `VotePrice` fee. +- **Reveal Phase**: After the commit phase ends, the `RevealDuration` begins. Voters must call `RevealVote`, submitting their original `value` and `salt`. The contract verifies that the hash matches the one submitted during the commit phase. + +### 6. Dispute Resolution (`ResolveDispute`) +Once the reveal period is over, anyone can call `ResolveDispute`. +- The `resolver.gno` contract tallies the votes. The winning value is the one with the highest cumulative token weight from voters. +- The `WinningValue` is set in the original `DataRequest`. +- **Slashing & Rewards**: The party (Proposer or Disputer) that lost the vote has their bond slashed. The winning party gets their bond back, and the slashed bond is distributed among the voters who voted for the winning outcome (voters who vote incorrectly lose 25% of their Oracle Token balance). + +## Architecture + +The oracle contract is composed of three main files: + +- `oracle.gno`: Manages the data request lifecycle (request, propose, dispute, resolve). It is the main entry point for users. +- `court.gno`: Handles the entire dispute resolution process, including the commit-reveal voting scheme. +- `resolver.gno`: Contains the business logic for tallying votes and determining the winning value of a dispute. It supports both Yes/No and Numeric resolutions. + +## User Roles + +- **Requester**: The user or contract that needs external data. They create the request and fund the reward. +- **Proposer**: The user who provides the initial answer to a data request and posts a bond. +- **Disputer**: A user who challenges a proposed value and posts a bond to initiate a vote. +- **Voter**: An Oracle Soulbound Token holder who participates in a dispute by voting on the correct outcome. + +## Tokenomics + +The token (OOT) is a non-transferable token that represents voting power in the oracle system. +- **Acquisition**: Users can acquire OOT by calling `BuyInitialVoteToken` and paying a fee in GNOT. This action mints one OOT to the caller's address (can only buy 1 initial token). +OOT holders can participate in disputes and earn rewards by voting correctly. By voting correctly, they can earn a portion of the slashed bonds from losing parties and gain 2 Vote tokens, incentivizing accurate and honest participation in the oracle system and increasing their voting power. +- **GNOT Usage**: The reward and the Bond (in GNOT Token) need to be less than the bond to avoid trying to game the system by creating disputes just to earn tokens, as they would lose more from the bond than the reward. +This design is not final and can be adjusted based on community feedback and economic analysis. + +## Usage Example + +Here is a full workflow using `gnokey`. +**0. Buy OOT for Voter Role (one time action)** +```bash +# Buy Oracle Soulbound Token (replace with your key name) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -func "BuyInitialVoteToken" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "1000000ugnot" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + + +**1. Request Data** +```bash +# Ask a Yes/No question: "Will ETH be below $4000 ?" (replace DEADLINE_TIMESTAMP with a future unix timestamp more than 24h from now) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -func "RequestData" -args "ETH below 4000$ ?" -args "true" -args "DEADLINE_TIMESTAMP" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "1000000ugnot" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +**2. Propose a Value** +```bash +# Propose "Yes" (value 1) (replace ID with the actual ID returned from the RequestData call) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -func "ProposeValue" -args "ID" -args "0" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "2000000ugnot" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +**If no one disputes within the liveness period, anyone can resolve the request:** +```bash +# Resolve the request (replace ID with the actual ID) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -func "ResolveRequest" -args "ID" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +**3. Dispute the Value** +```bash +# Dispute the proposal (replace ID with the actual ID) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -func "DisputeData" -args "ID" -gas-fee 1000000ugnot -gas-wanted 5000000 -send "2000000ugnot" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +**4. Vote on the Dispute** +First, generate a hash locally. Let's vote "No" (value 0) with salt "mysecret". +Hash: `sha256("0" + "test")` -> `a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21` +```bash +# Commit the vote (replace ID with the actual ID) +ggnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -func "VoteOnDispute" -args "ID" -args "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21" -gas-fee 1000000ugnot -gas-wanted 5000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +**5. Reveal the Vote** +```bash +# Reveal the vote after the voting period ends (replace ID with the actual ID) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -func "RevealVote" -args "ID" -args "0" -args "test" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +**6. Resolve the Dispute** +```bash +# After the reveal period, anyone can trigger the final resolution (replace ID with the actual ID). +gnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -func "ResolveDispute" -args "ID" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +When testing with `gnodev` locally, ensure to make transactions between waiting periods as `gnodev` only creates blocks when a transaction is made, and the oracle relies on current block timestamps. + +## Warning + +This is a simplified example for educational purposes. In a production environment, consider additional security measures, optimizations, and edge cases. + + +## Developer + +| [
Lucas Leclerc](https://github.com/intermarch3) | +| :---: | \ No newline at end of file diff --git a/packages/r/intermarch3/goo/court.gno b/packages/r/intermarch3/goo/court.gno new file mode 100644 index 0000000..685c906 --- /dev/null +++ b/packages/r/intermarch3/goo/court.gno @@ -0,0 +1,254 @@ +package goo + +import ( + "chain" + "chain/banker" + "chain/runtime" + "crypto/sha256" + "encoding/hex" + "strconv" + "time" + + "gno.land/p/nt/avl" + "gno.land/p/nt/ufmt" +) + +var ( + disputes = avl.NewTree() + disputeDuration = 2 * int64(time.Minutes.Seconds()) + revealDuration = 2 * int64(time.Minute.Seconds()) + voteTokenPrice = 1 * int64(1_000_000) // in GNOT + VoteToken = newOOToken("Gno Optimistic Oracle Token", "goot", 6) +) + +func initiateDispute(id string) { + if _, exists := disputes.Get(id); exists { + panic("error: Dispute for this request already exists.") + } + dispute := &Dispute{ + RequestId: id, + Votes: []Vote{}, + Voters: avl.NewTree(), + IsResolved: false, + EndTime: time.Now().Add(time.Duration(disputeDuration) * time.Second), + EndRevealTime: time.Now().Add(time.Duration(disputeDuration+revealDuration) * time.Second), + } + disputes.Set(id, dispute) + chain.Emit("DisputeInitiated", "id", id) +} + +// -- PUBLIC FUNCTIONS -- + +// BuyInitialVoteToken allows a user to buy their first vote token by sending voteTokenPrice amount of ugnot. +func BuyInitialVoteToken(_ realm) { + caller := runtime.OriginCaller() + coins := banker.OriginSend() + if len(coins) != 1 || coins.AmountOf("ugnot") != voteTokenPrice { + panic(ufmt.Sprintf("error: Must send exactly %d gnot to get a vote token.", voteTokenPrice/1_000_000)) + } + + balance := VoteToken.BalanceOf(caller) + if balance > 0 { + panic("error: You already have a vote token.") + } + + VoteToken.mint(caller, 1) + chain.Emit("VoteTokenPurchased", "voter", caller.String()) +} + +// VoteOnDispute allows a user to commit a vote during a dispute. +func VoteOnDispute(_ realm, id string, hash string) { + dispute := getDispute(id) + request := getRequest(id) + + if request.Proposer == runtime.PreviousRealm().Address() || request.Disputer == runtime.PreviousRealm().Address() { + panic("error: Proposer and Disputer cannot vote in this dispute.") + } + + if dispute.IsResolved { + panic("error: Dispute is already resolved.") + } + + if time.Now().After(dispute.EndTime) { + panic("error: Vote period has ended.") + } + + amount := VoteToken.BalanceOf(runtime.PreviousRealm().Address()) + if amount < 1 { + panic("error: You need at least 1 vote token to vote.") + } + + vote := Vote{ + RequestId: id, + Voter: runtime.PreviousRealm().Address(), + TokenAmount: amount, + Hash: hash, + Revealed: false, + } + + voter, exist := dispute.Voters.Get(string(vote.Voter)) + if exist && voter.(Voter).HasVoted { + panic("error: Voter has already voted in this dispute.") + } + + dispute.Votes = append(dispute.Votes, vote) + dispute.Voters.Set(string(vote.Voter), Voter{HasVoted: true, VoteIndex: int64(len(dispute.Votes) - 1)}) + chain.Emit("VoteSubmitted", "id", id, "voter", vote.Voter.String()) +} + +// RevealVote allows a user to reveal their vote after the voting period has ended. +func RevealVote(_ realm, id string, value int64, salt string) { + dispute := getDispute(id) + + if dispute.IsResolved { + panic("error: Dispute is resolved.") + } + + if time.Now().Before(dispute.EndTime) { + panic("error: Vote period has not ended yet.") + } + + if time.Now().After(dispute.EndRevealTime) { + panic("error: Reveal period has ended.") + } + + voter, exist := dispute.Voters.Get(string(runtime.PreviousRealm().Address())) + if !exist || !voter.(Voter).HasVoted { + panic("error: Voter did not participate in this dispute.") + } + + vote := dispute.Votes[voter.(Voter).VoteIndex] + if vote.Revealed { + panic("error: Vote already revealed.") + } + + // Verify the hash + res := sha256.Sum256([]byte(strconv.FormatInt(value, 10) + salt)) + expectedHash := hex.EncodeToString(res[:]) + if vote.Hash != expectedHash { + panic("error: Hash does not match the revealed value and salt.") + } + + vote.Value = value + vote.Revealed = true + dispute.NbResolvedVotes += 1 + dispute.Votes[voter.(Voter).VoteIndex] = vote + chain.Emit("VoteRevealed", "id", id, "voter", vote.Voter.String(), "value", strconv.Itoa(int(value))) +} + +// ResolveDispute finalizes a dispute after the reveal period, tallying votes and setting the winning value. +func ResolveDispute(_ realm, id string) { + dispute := getDispute(id) + if dispute.IsResolved { + panic("error: Dispute is already resolved.") + } + + if time.Now().Before(dispute.EndTime) { + panic("error: Dispute period has not ended yet.") + } + + val := resolve(id) + dispute.WinningValue = val + dispute.IsResolved = true + // Update the original request with the winning value + request := getRequest(id) + + request.ProposedValue = val + request.State = "Resolved" + chain.Emit("DisputeResolved", "id", id, "winningValue", strconv.Itoa(int(val))) + chain.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(val))) + + var winner address + if val != request.ProposedValue { + // Refund + reward the disputer if the dispute changed the value + winner = request.Disputer + } else { + // Refund + reward the proposer if the dispute did not change the value + winner = request.Proposer + } + bank.SendCoins(runtime.CurrentRealm().Address(), winner, chain.Coins{chain.Coin{Denom: "ugnot", Amount: bond + requesterReward}}) +} + +// -- admin functions -- + +// SetDisputeDuration sets the duration (in seconds) for the voting period. +func SetDisputeDuration(_ realm, duration int64) { + if runtime.OriginCaller() == admin { + disputeDuration = duration * int64(time.Second) + } else { + panic("error: Only admin can set dispute duration.") + } +} + +// SetRevealDuration sets the duration (in seconds) for the reveal period. +func SetRevealDuration(_ realm, duration int64) { + if runtime.OriginCaller() == admin { + revealDuration = duration * int64(time.Second) + } else { + panic("error: Only admin can set reveal duration.") + } +} + +// SetVoteTokenPrice sets the price (in ugnot) to cast a vote. +func SetVoteTokenPrice(_ realm, price int64) { + if runtime.OriginCaller() == admin { + voteTokenPrice = price + } else { + panic("error: Only admin can set vote price.") + } +} + +// -- view functions -- + +// BalanceOfVoteToken returns the number of vote tokens held by the caller. +func BalanceOfVoteToken(_ realm) int64 { + return VoteToken.BalanceOf(runtime.PreviousRealm().Address()) +} + +// GetDispute returns the details of a specific dispute. +func GetDispute(id string) *Dispute { + return getDispute(id) +} + +// GetDisputeDuration returns the current dispute duration. +func GetDisputeDuration() int64 { + return disputeDuration +} + +// GetVoteTokenPrice returns the current vote price. +func GetVoteTokenPrice() int64 { + return voteTokenPrice +} + +// GetDisputeEndTime returns the end time of the voting period for a specific dispute. +func GetDisputeEndTime(id string) time.Time { + dispute := getDispute(id) + return dispute.EndTime +} + +// GetDisputeVotesAmount returns the total number of votes cast in a dispute. +func GetDisputeVotesAmount(id string) int64 { + dispute := getDispute(id) + return int64(len(dispute.Votes)) +} + +// GetRevealEndTime returns the end time of the reveal period for a specific dispute. +func GetRevealEndTime(id string) time.Time { + dispute := getDispute(id) + return dispute.EndRevealTime +} + +// GetRevealDuration returns the current reveal duration. +func GetRevealDuration() int64 { + return revealDuration +} + +// Utils functions + +func getDispute(id string) *Dispute { + dispute, exists := disputes.Get(id) + if !exists { + panic("error: Dispute with this ID does not exist.") + } + return dispute.(*Dispute) +} diff --git a/packages/r/intermarch3/goo/court_test.gno b/packages/r/intermarch3/goo/court_test.gno new file mode 100644 index 0000000..a49f72b --- /dev/null +++ b/packages/r/intermarch3/goo/court_test.gno @@ -0,0 +1,148 @@ +package goo + +import ( + "chain" + "strconv" + "testing" + "time" + + "gno.land/p/nt/testutils" + "gno.land/p/nt/urequire" +) + +var user4 = testutils.TestAddress("user4") + +func TestBuyInitialVoteToken(t *testing.T) { + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Must send exactly "+strconv.Itoa(int(voteTokenPrice/1_000_000))+" gnot to get a vote token.", func() { + BuyInitialVoteToken(cross) + }, "user should not be able to buy a vote token without sending the correct amount") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: voteTokenPrice}}) + urequire.NotPanics(t, func() { + BuyInitialVoteToken(cross) + }, "user should be able to buy a vote token by sending the correct amount") + amount := VoteToken.BalanceOf(user1) + urequire.Equal(t, int64(1), amount, "user should have received a vote token") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: voteTokenPrice}}) + urequire.AbortsWithMessage(t, "error: You already have a vote token.", func() { + BuyInitialVoteToken(cross) + }, "user should not be able to buy a second vote token") +} + +func TestVoteOnDispute(t *testing.T) { + // setup: create request and dispute + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + DisputeData(cross, id) + + // buy vote token + testing.SetRealm(testing.NewUserRealm(user3)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: voteTokenPrice}}) + BuyInitialVoteToken(cross) + + // vote on dispute + testing.SetRealm(testing.NewUserRealm(user3)) + urequire.NotPanics(t, func() { + VoteOnDispute(cross, id, "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21") + }, "user should be able to vote on dispute") + + testing.SetRealm(testing.NewUserRealm(user4)) + urequire.AbortsWithMessage(t, "error: You need at least 1 vote token to vote.", func() { + VoteOnDispute(cross, id, "hash") + }, "user should not be able to vote on dispute without a vote token") + + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Proposer and Disputer cannot vote in this dispute.", func() { + VoteOnDispute(cross, id, "hash") + }, "user should not be able to vote on dispute if they are the proposer or disputer") + + testing.SetRealm(testing.NewUserRealm(user3)) + urequire.AbortsWithMessage(t, "error: Voter has already voted in this dispute.", func() { + VoteOnDispute(cross, id, "hash") + }, "user should not be able to vote on dispute if they have already voted") + + testing.SetRealm(testing.NewUserRealm(user4)) + setTime(time.Now().Add(time.Duration(disputeDuration)*time.Second + time.Second)) + urequire.AbortsWithMessage(t, "error: Vote period has ended.", func() { + VoteOnDispute(cross, id, "hash") + }, "user should not be able to vote on dispute after the voting period has ended") +} + +func TestRevealVote(t *testing.T) { + // setup: create request and dispute + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + DisputeData(cross, id) + + // vote on dispute + testing.SetRealm(testing.NewUserRealm(user3)) + VoteOnDispute(cross, id, "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21") + + testing.SetRealm(testing.NewUserRealm(user3)) + setTime(time.Now().Add(time.Duration(disputeDuration)*time.Second + time.Second)) + urequire.AbortsWithMessage(t, "error: Hash does not match the revealed value and salt.", func() { + RevealVote(cross, id, 1, "mysalt") + }, "vote reveal with incorrect value and salt should fail") + + // reveal vote + testing.SetRealm(testing.NewUserRealm(user3)) + urequire.NotPanics(t, func() { + RevealVote(cross, id, 0, "test") + }, "user should be able to reveal their vote") + + testing.SetRealm(testing.NewUserRealm(user3)) + urequire.AbortsWithMessage(t, "error: Vote already revealed.", func() { + RevealVote(cross, id, 1, "mysalt") + }, "user should not be able to reveal their vote again") +} + +func TestResolveDispute(t *testing.T) { + // setup: create request and dispute + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + DisputeData(cross, id) + + // vote on dispute + testing.SetRealm(testing.NewUserRealm(user3)) + VoteOnDispute(cross, id, "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21") + setTime(time.Now().Add(time.Duration(disputeDuration)*time.Second + time.Second)) + RevealVote(cross, id, 0, "test") + + setTime(time.Now().Add(time.Duration(revealDuration)*time.Second + time.Second)) + CreateGnotCoins(cross, (bond*2)+requesterReward) + urequire.NotPanics(t, func() { + ResolveDispute(cross, id) + }, "user should be able to resolve dispute after the reveal period has ended") + + urequire.AbortsWithMessage(t, "error: Dispute is already resolved.", func() { + ResolveDispute(cross, id) + }, "user should not be able to resolve dispute before the reveal period has ended") +} diff --git a/packages/r/intermarch3/goo/gnomod.toml b/packages/r/intermarch3/goo/gnomod.toml new file mode 100644 index 0000000..8c11fe4 --- /dev/null +++ b/packages/r/intermarch3/goo/gnomod.toml @@ -0,0 +1,2 @@ +module = "gno.land/r/intermarch3/goo" +gno = "0.9" diff --git a/packages/r/intermarch3/goo/oracle.gno b/packages/r/intermarch3/goo/oracle.gno new file mode 100644 index 0000000..2a87fc0 --- /dev/null +++ b/packages/r/intermarch3/goo/oracle.gno @@ -0,0 +1,295 @@ +package goo + +import ( + "chain" + "chain/banker" + "chain/runtime" + "strconv" + "time" + + "gno.land/p/nt/avl" + "gno.land/p/nt/seqid" + "gno.land/p/nt/ufmt" +) + +var ( + admin address + resolutionTime = 2 * int64(time.Minute.Seconds()) + requesterReward = 1 * int64(1_000_000) // in GNOT + bond = 2 * int64(1_000_000) // in GNOT + requests = avl.NewTree() + bank = banker.NewBanker(banker.BankerTypeRealmSend) + idGenerator seqid.ID +) + +func init() { + admin = runtime.CurrentRealm().Address() +} + +// -- PUBLIC FUNCTIONS -- + +// RequestData allows a user to request data from the oracle. +// `requesterReward` value needs to be sent to the contract as a reward +// `deadline`is the timestamp in unix format for the deadline of the request. +// You need to ask a question that can be answered with a single number like a yes/no question (0 or 1) or a specific value (e.g. ETH/USD price). +func RequestData(_ realm, ancillaryData string, yesNoQuestion bool, deadline int64) string { + if ancillaryData == "" { + panic("error: Ancillary data cannot be empty.") + } + + if deadline < time.Now().Add(24*time.Hour).Unix() { + panic("error: Deadline must be at least 24 hours in the future.") + } + + coins := banker.OriginSend() + if len(coins) != 1 || coins.AmountOf("ugnot") != requesterReward { + panic(ufmt.Sprintf("error: Incorrect reward amount sent. Required: %d ugnot.", requesterReward)) + } + + id := idGenerator.Next().String() + + request := &DataRequest{ + Id: id, + Timestamp: time.Now(), + AncillaryData: ancillaryData, + YesNoQuestion: yesNoQuestion, + State: "Requested", + Deadline: time.Unix(deadline, 0), + Creator: runtime.PreviousRealm().Address(), + } + requests.Set(id, request) + chain.Emit("DataRequested", "id", id, "timestamp", request.Timestamp.String(), "ancillaryData", ancillaryData) + return id +} + +// ProposeValue allows a user to propose a value for a requested data point. +// `bond` value needs to be sent to the contract as a bond. +func ProposeValue(_ realm, id string, proposedValue int64) { + request := getRequest(id) + if request.State != "Requested" { + panic("error: Request is not in 'Requested' state.") + } + + if request.YesNoQuestion && proposedValue != 0 && proposedValue != 1 { + panic("error: Proposed value must be 0 or 1 for yes/no questions.") + } + + if time.Now().After(request.Deadline) { + panic("error: Deadline for proposal has passed.") + } + + coins := banker.OriginSend() + if len(coins) != 1 || coins.AmountOf("ugnot") != bond { + panic("error: Incorrect bond amount sent. Required: " + strconv.FormatInt(bond, 10) + " ugnot") + } + + request.ProposedValue = proposedValue + request.Proposer = runtime.PreviousRealm().Address() + request.ProposerBond = bond + request.ResolutionTime = time.Now().Add(time.Duration(resolutionTime) * time.Second) + request.State = "Proposed" + chain.Emit("ValueProposed", "id", id, "proposedValue", strconv.Itoa(int(proposedValue)), "proposer", request.Proposer.String(), "resolutionTime", request.ResolutionTime.String()) +} + +// DisputeData allows a user to dispute a proposed value. +// `bond` value needs to be sent to the contract as a bond. +func DisputeData(_ realm, id string) string { + request := getRequest(id) + if request.Proposer == runtime.PreviousRealm().Address() { + panic("error: Proposer cannot dispute their own proposal.") + } + + if request.State != "Proposed" { + panic("error: Request is not in 'Proposed' state.") + } + + if time.Now().After(request.ResolutionTime) { + panic("error: Dispute period has ended.") + } + + coins := banker.OriginSend() + if len(coins) != 1 || coins.AmountOf("ugnot") != bond { + panic("error: Incorrect bond amount sent. Required: " + strconv.FormatInt(bond, 10) + " ugnot") + } + + request.Disputer = runtime.PreviousRealm().Address() + request.DisputerBond = bond + request.State = "Disputed" + initiateDispute(id) + chain.Emit("DataDisputed", "id", id, "disputer", request.Disputer.String()) + return "Time: " + time.Now().String() +} + +// ResolveRequest finalizes an undisputed request after the resolution period has passed. +func ResolveRequest(_ realm, id string) { + request := getRequest(id) + if request.State == "Disputed" { + panic("error: Request is in 'Disputed' state.") + } + + if request.State == "Proposed" && request.ResolutionTime.After(time.Now()) { + panic("error: Resolution period has not ended yet.") + } + + if request.State == "Requested" { + panic("error: Request has not been proposed yet.") + } + + if request.State == "Resolved" { + panic("error: Request is already resolved.") + } + + request.State = "Resolved" + request.WinningValue = request.ProposedValue + + from := runtime.CurrentRealm().Address() + to := request.Proposer + totalPayout := request.ProposerBond + requesterReward + payout := chain.Coins{chain.Coin{Denom: "ugnot", Amount: totalPayout}} + bank.SendCoins(from, to, payout) + + chain.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(request.WinningValue))) +} + +// RequestResult returns the winning value of a resolved request. +func RequestResult(_ realm, id string) int64 { + request := getRequest(id) + if request.State != "Resolved" { + panic("error: Request is not resolved.") + } + return request.WinningValue +} + +// RequesterRetreiveFund allows the original requester to get their reward back if the deadline passed without a proposal. +func RequesterRetreiveFund(_ realm, id string) { + request := getRequest(id) + if request.State != "Requested" { + panic("error: cannot retreive fund as requests fulfilled.") + } + + if request.Creator != runtime.PreviousRealm().Address() { + panic("error: Only the creator of the request can retrieve the fund.") + } + + if request.Deadline.After(time.Now()) { + panic("error: Cannot retrieve fund before the deadline.") + } + + from := runtime.CurrentRealm().Address() + to := request.Creator + refund := chain.Coins{chain.Coin{Denom: "ugnot", Amount: requesterReward}} + bank.SendCoins(from, to, refund) + + request.State = "Expired" + chain.Emit("RequestExpired", "id", id) +} + +// -- ADMIN FUNCTIONS -- + +// SetResolutionDuration sets the duration (in seconds) for the resolution period. +func SetResolutionDuration(_ realm, duration int64) { + if runtime.OriginCaller() == admin { + resolutionTime = duration + chain.Emit("resolutionTimeSet", "duration", strconv.Itoa(int(duration))) + } else { + panic("error: Only the admin can set the resolution time.") + } +} + +// SetrequesterReward sets the reward amount for a successful proposal. +func SetrequesterReward(_ realm, reward int64) { + if runtime.OriginCaller() == admin { + requesterReward = reward + chain.Emit("requesterRewardSet", "reward", strconv.Itoa(int(reward))) + } else { + panic("error: Only the admin can set the requester reward.") + } +} + +// SetBond sets the bond amount required for proposals and disputes. +func SetBond(_ realm, newBond int64) { + if runtime.OriginCaller() == admin { + bond = newBond + chain.Emit("bondSet", "bond", strconv.Itoa(int(bond))) + } else { + panic("error: Only the admin can set the proposer bond.") + } +} + +// ChangeAdmin transfers admin privileges to a new address. +func ChangeAdmin(_ realm, newAdmin address) { + if runtime.OriginCaller() == admin { + admin = newAdmin + chain.Emit("AdminChanged", "newAdmin", newAdmin.String()) + } else { + panic("error: Only the admin can change the admin.") + } +} + +// -- VIEW FUNCTIONS -- + +// GetRequest returns the details of a specific data request. +func GetRequest(id string) *DataRequest { + return getRequest(id) +} + +// GetRequestsIds returns the IDs of all requests. +func GetRequestsIds() []string { + var ids []string + requests.Iterate("", "", func(key string, value any) bool { + ids = append(ids, key) + return false + }) + return ids +} + +// GetOpenRequests returns the IDs of all open requests. +func GetRequestsIdsWithState(state string) []string { + if state != "Requested" && state != "Proposed" && state != "Disputed" && state != "Resolved" { + panic("error: Invalid state.") + } + var ids []string + requests.Iterate("", "", func(key string, value any) bool { + if value.(DataRequest).State == state { + ids = append(ids, key) + } + return false + }) + return ids +} + +// GetBond returns the current bond amount. +func GetBond() int64 { + return bond +} + +// GetResolutionTime returns the current resolution time duration. +func GetResolutionTime() int64 { + return resolutionTime +} + +// GetRequesterReward returns the current requester reward amount. +func GetRequesterReward() int64 { + return requesterReward +} + +// GetRequeststate returns the current state of a specific data request. +func GetRequeststate(id string) string { + request := getRequest(id) + return request.State +} + +// GetAdmin returns the current admin address. +func GetAdmin() address { + return admin +} + +// Utils functions + +func getRequest(id string) *DataRequest { + request, exists := requests.Get(id) + if !exists { + panic("error: Request with this ID does not exist.") + } + return request.(*DataRequest) +} diff --git a/packages/r/intermarch3/goo/oracle_test.gno b/packages/r/intermarch3/goo/oracle_test.gno new file mode 100644 index 0000000..d79cf6b --- /dev/null +++ b/packages/r/intermarch3/goo/oracle_test.gno @@ -0,0 +1,203 @@ +package goo + +import ( + "chain" + "chain/runtime" + "strconv" + "testing" + "time" + + "gno.land/p/nt/testutils" + "gno.land/p/nt/urequire" +) + +var ( + user1 = testutils.TestAddress("user1") + user2 = testutils.TestAddress("user2") + user3 = testutils.TestAddress("user3") +) + +func TestRequestData(t *testing.T) { + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Incorrect reward amount sent. Required: "+strconv.FormatInt(requesterReward, 10)+" ugnot.", func() { + RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + }, "user should not be able to request data without sending the requester reward") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + urequire.AbortsWithMessage(t, "error: Ancillary data cannot be empty.", func() { + RequestData(cross, "", true, time.Now().Add(24*time.Hour).Unix()) + }, "user should not be able to request data with an empty ancillary data") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + urequire.AbortsWithMessage(t, "error: Deadline must be at least 24 hours in the future.", func() { + RequestData(cross, "test", true, time.Now().Unix()) + }, "user should not be able to request data with a deadline less than 24 hours in the future") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + urequire.NotPanics(t, func() { + RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + }, "user should be able to request data") +} + +func TestProposeValue(t *testing.T) { + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Request with this ID does not exist.", func() { + ProposeValue(cross, "test", 0) + }, "request id does not exist but didn't revert") + + // create a request + now := time.Now() + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Proposed value must be 0 or 1 for yes/no questions.", func() { + ProposeValue(cross, id, 9) + }, "user should not be able to propose a value other than 0 or 1 for a yes/no question") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: 0}}) + urequire.AbortsWithMessage(t, "error: Incorrect bond amount sent. Required: "+strconv.FormatInt(bond, 10)+" ugnot", func() { + ProposeValue(cross, id, 0) + }, "user should not be able to propose a value without sending the bond") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + setTime(time.Now().Add(25 * time.Hour)) + urequire.AbortsWithMessage(t, "error: Deadline for proposal has passed.", func() { + ProposeValue(cross, id, 0) + }, "user should not be able to propose a value after the deadline") + + testing.SetRealm(testing.NewUserRealm(user1)) + setTime(now.Add(time.Hour)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.NotPanics(t, func() { + ProposeValue(cross, id, 0) + }, "user should be able to propose a value") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Request is not in 'Requested' state.", func() { + ProposeValue(cross, id, 0) + }, "user should not be able to propose a value if the request is not in 'Requested' state") +} + +func TestDisputeData(t *testing.T) { + // setup: create request and propose a value + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + now := time.Now() + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Request with this ID does not exist.", func() { + DisputeData(cross, "test") + }, "request id does not exist but didn't revert") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Proposer cannot dispute their own proposal.", func() { + DisputeData(cross, id) + }, "proposer cannot dispute their own proposal") + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: 0}}) + urequire.AbortsWithMessage(t, "error: Incorrect bond amount sent. Required: "+strconv.FormatInt(bond, 10)+" ugnot", func() { + DisputeData(cross, id) + }, "user should not be able to dispute a value without sending the bond") + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + setTime(time.Now().Add(time.Duration(resolutionTime)*time.Second + time.Second)) + urequire.AbortsWithMessage(t, "error: Dispute period has ended.", func() { + DisputeData(cross, id) + }, "user should not be able to dispute a value after the dispute period has ended") + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + setTime(now.Add(time.Second)) + urequire.NotPanics(t, func() { + DisputeData(cross, id) + }, "user should be able to dispute a value") + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Request is not in 'Proposed' state.", func() { + DisputeData(cross, id) + }, "user should not be able to dispute a value if the request is not in 'Proposed' state") +} + +func TestResolveRequest(t *testing.T) { + // setup: create request + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + now := time.Now() + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Request has not been proposed yet.", func() { + ResolveRequest(cross, id) + }, "user should not be able to resolve a request if it has not been proposed yet") + + // propose a value + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend(chain.Coins{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Resolution period has not ended yet.", func() { + ResolveRequest(cross, id) + }, "user should not be able to resolve a request if the resolution period has not ended yet") + + setTime(now.Add(time.Duration(resolutionTime)*time.Second + time.Second)) + CreateGnotCoins(cross, bond+requesterReward) + urequire.NotPanics(t, func() { + ResolveRequest(cross, id) + }, "user should be able to resolve a request") + + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Request is already resolved.", func() { + ResolveRequest(cross, id) + }, "user should not be able to resolve a request if it is already resolved") +} + +func TestRequesterRetreiveFund(t *testing.T) { + // setup: create request + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + duration := time.Now().Add(24 * time.Hour) + id := RequestData(cross, "test", true, duration.Unix()) + + testing.SetRealm(testing.NewUserRealm(user2)) + urequire.AbortsWithMessage(t, "error: Only the creator of the request can retrieve the fund.", func() { + RequesterRetreiveFund(cross, id) + }, "only requester should be able to retrieve funds") + + testing.SetRealm(testing.NewUserRealm(user1)) + setTime(duration.Add(time.Hour)) + CreateGnotCoins(cross, requesterReward) + urequire.NotPanics(t, func() { + RequesterRetreiveFund(cross, id) + }, "requester should be able to retrieve funds") +} + +// Helper functions + +func setTime(newTime time.Time) { + ctx := testing.GetContext() + ctx.Time = newTime + testing.SetContext(ctx) +} + +func CreateGnotCoins(_ realm, amount int64) { + testing.IssueCoins(runtime.CurrentRealm().Address(), chain.Coins{{Denom: "ugnot", Amount: amount}}) +} diff --git a/packages/r/intermarch3/goo/render.gno b/packages/r/intermarch3/goo/render.gno new file mode 100644 index 0000000..b04328c --- /dev/null +++ b/packages/r/intermarch3/goo/render.gno @@ -0,0 +1,221 @@ +package goo + +import ( + "chain/runtime" + "strconv" + "time" + + "gno.land/p/moul/md" + "gno.land/p/moul/realmpath" + "gno.land/p/moul/txlink" + "gno.land/p/nt/ufmt" +) + +const DateFormat = "January 2 2006, 03:04:04 PM" + +func Render(path string) string { + req := realmpath.Parse(path) + if req.Path == "" { + return renderHome() + } + return renderRequestPage(req.Path) +} + +func renderHome() string { + msg := md.H1("GOO Home") + msg += md.Paragraph("Welcome to the first Optimistic Oracle on GnoLand! This project is developed by " + md.Link("@intermarch3", "/r/intermarch3/home")) + msg += md.Paragraph("For more information, visit the " + md.Link("Readme", runtime.CurrentRealm().PkgPath()[8:]+"$source") + ".") + msg += md.Link("Request Data", ufmt.Sprintf("%s", txlink.NewLink("RequestData").AddArgs("ancillaryData", "YOUR_QUESTION", "yesNoQuestion", "true or false", "deadline", "DEADLINE_TIMESTAMP").SetSend(strconv.Itoa(int(requesterReward))+"ugnot").URL())) + "\n\n(Note: Requesting data need to pay a reward of " + strconv.Itoa(int(requesterReward/1_000_000)) + " GNOT to the proposer).\n\n" + msg += md.H2("Current requests :") + + // List current requests and build table + var table []string + table = append(table, md.Bold("Question"), md.Bold("Proposed Value"), md.Bold("State"), md.Bold("See more")) + + requests.Iterate("", "", func(key string, value any) bool { + if value.(*DataRequest).State == "Resolved" { + return false + } + table = append(table, renderRequest(value.(*DataRequest))...) + return false + }) + msg += md.ColumnsN(table, 4, false) + + // If no current requests, show a message + if len(table) == 4 { + msg += md.Paragraph("No current requests.") + } + + msg += md.HorizontalRule() + + // List last 5 resolved requests and build table + msg += md.H2("Last 5 Resolved requests:") + var resolved []string + resolved = append(resolved, md.Bold("Question"), md.Bold("Winning Value"), md.Bold("Proposer"), md.Bold("See more")) + + requests.Iterate("", "", func(key string, value any) bool { + if value.(*DataRequest).State != "Resolved" { + return false + } + resolved = append(resolved, md.Paragraph(value.(*DataRequest).AncillaryData)) + resolved = append(resolved, md.Paragraph(rendervalue(value.(*DataRequest), value.(*DataRequest).WinningValue))) + resolved = append(resolved, md.Paragraph(value.(*DataRequest).Proposer.String())) + resolved = append(resolved, md.Link("Click here", runtime.CurrentRealm().PkgPath()[8:]+":"+value.(*DataRequest).Id)) + return false + }) + msg += md.ColumnsN(resolved, 4, false) + + // If no resolved requests, show a message + if len(resolved) == 4 { + msg += md.Paragraph("No resolved requests yet.") + } + return msg +} + +// renderRequestPage renders the page for a specific request +func renderRequestPage(id string) string { + req := getRequest(id) + + msg := md.H1("Question: " + req.AncillaryData) + msg += md.H2("Details:\n\n") + msg += md.H3("Request ID: "+req.Id) + "\n\n" + + // check if the request is past the deadline + msg += md.H3("Requested at: "+req.Timestamp.Format(DateFormat)) + "\n\n" + if req.State == "Requested" && req.Deadline.Unix() < time.Now().Unix() { + msg += md.H3("Deadline Missed: requests not fulfilled") + "\n\n" + return msg + } + + // show the state of the request + msg += md.H3("State: "+req.State) + "\n\n" + if req.State == "Requested" { + msg += md.Paragraph("This request has not been proposed yet.") + "\n\n" + msg += md.H3("The reward for the proposer is "+strconv.Itoa(int(requesterReward/1_000_000))+" GNOT.") + "\n\n" + msg += renderAction(req) + return msg + } + + // if the request is resolved, show the winning value and the number of votes + if req.State == "Resolved" { + msg += md.H3("Winning Value: "+rendervalue(req, req.WinningValue)) + "\n\n" + res, exists := disputes.Get(req.Id) + if !exists { + msg += md.Paragraph("Value Proposed by: "+req.Proposer.String()) + "\n\n" + return msg + } + + dispute := res.(*Dispute) + msg += md.Paragraph("Number of Votes: " + strconv.Itoa(len(dispute.Votes)) + "\n\n") + msg += md.Paragraph("Reveal Votes: \n\n") + if dispute.NbResolvedVotes > 0 { + msg += renderRevealedVotes(*dispute) + "\n\n" + } else { + msg += md.Paragraph("No votes revealed. Proposed value win by default\n\n") + } + return msg + } + + // if the request is proposed, show the proposed value and the end resolution time + if req.State == "Proposed" { + msg += md.Paragraph("Proposed by: "+req.Proposer.String()) + "\n\n" + msg += md.H3("Proposed Value: "+rendervalue(req, req.ProposedValue)) + "\n\n" + msg += md.H3("End Resolution Time: "+req.ResolutionTime.Format(DateFormat)) + "\n\n" + } + + // if the request is disputed, show the dispute details + if req.State == "Disputed" { + res, exist := disputes.Get(req.Id) + if exist { + dispute := res.(*Dispute) + msg += md.H2("Dispute Details:\n\n") + msg += md.Paragraph("End of Voting Time: " + dispute.EndTime.Format(DateFormat) + "\n\n") + msg += md.Paragraph("End of Reveal Time: " + dispute.EndRevealTime.Format(DateFormat)) + msg += md.Paragraph("Number of Votes: " + strconv.Itoa(len(dispute.Votes)) + "\n\n") + msg += md.Paragraph("Revealed Votes: \n\n") + + if dispute.NbResolvedVotes > 0 { + msg += renderRevealedVotes(*dispute) + "\n\n" + } else { + msg += md.Paragraph("No votes revealed yet.\n\n") + } + } else { + msg += md.Paragraph("No dispute details available.\n\n") + } + } + + msg += renderAction(req) + return msg +} + +// create table of the votes that have been revealed +func renderRevealedVotes(dispute Dispute) string { + var table []string + + table = append(table, md.Bold("Voter"), md.Bold("Vote"), md.Bold("Token Amount")) + request := getRequest(dispute.RequestId) + + for _, vote := range dispute.Votes { + if vote.Revealed { + table = append(table, md.Paragraph(vote.Voter.String()), md.Paragraph(rendervalue(request, vote.Value)), md.Paragraph(strconv.Itoa(int(vote.TokenAmount)))) + } + } + return md.ColumnsN(table, 3, false) +} + +func rendervalue(req *DataRequest, val int64) string { + if req.YesNoQuestion { + if val == 1 { + return "Yes" + } else if val == 0 { + return "No" + } else { + return "Invalid value for Yes/No question" + } + } else { + return strconv.Itoa(int(val)) + } +} + +func renderRequest(req *DataRequest) []string { + var table []string + + table = append(table, md.Paragraph(req.AncillaryData)) + if req.State == "Requested" { + table = append(table, md.Paragraph("N/A")) + } else { + table = append(table, md.Paragraph(rendervalue(req, req.ProposedValue))) + } + table = append(table, md.Paragraph(req.State)) + table = append(table, md.Link("Click here", runtime.CurrentRealm().PkgPath()[8:]+":"+req.Id)) + return table +} + +// return appropriate action link based on the state of the request +func renderAction(req *DataRequest) string { + action := md.H2("Actions:\n\n") + dispute, _ := disputes.Get(req.Id) + + if req.State == "Requested" { + // tx to propose a value + action += md.Link("Propose a value", ufmt.Sprintf("%s", txlink.NewLink("ProposeValue").AddArgs("id", req.Id, "proposedValue", "YOUR_VALUE_HERE").SetSend(strconv.Itoa(int(bond))+"ugnot").URL())) + "\n\n(Note: Proposing need to bond " + strconv.Itoa(int(bond/1_000_000)) + " GNOT)" + } else if req.State == "Proposed" { + // tx to dispute a value + action += md.Link("Dispute value", ufmt.Sprintf("%s", txlink.NewLink("DisputeData").AddArgs("id", req.Id).SetSend(strconv.Itoa(int(bond))+"ugnot").URL())) + "\n\n(Note: Disputing need to bond " + strconv.Itoa(int(bond/1_000_000)) + " GNOT)" + } else if req.State == "Disputed" { + if req.ResolutionTime.Before(time.Now()) { + // tx to reveal a vote + action += md.Link("Reveal vote", ufmt.Sprintf("%s", txlink.NewLink("RevealVote").AddArgs("id", req.Id, "value", "YOUR_VALUE_HERE", "salt", "YOUR_SALT_HERE").URL())) + "\n\n(Note: You must reveal your vote before the reveal period ends at " + dispute.(*Dispute).EndRevealTime.UTC().Format(time.UnixDate) + ")" + } else { + // tx to vote on a dispute + action += md.Link("Vote", ufmt.Sprintf("%s", txlink.NewLink("VoteOnDispute").AddArgs("id", req.Id, "hash", "YOUR_HASH_HERE").URL())) + "\n\n(Note: Voting need to hold a Vote Token and requires you to hash your vote with a salt. Use a tool to generate the sha256 hash and keep your salt safe to reveal your vote)" + } + } else if req.State == "Disputed" && dispute.(*Dispute).EndTime.Before(time.Now()) { + // tx to resolve a dispute + action += md.Link("Resolve dispute", ufmt.Sprintf("%s", txlink.NewLink("ResolveDispute").AddArgs("id", req.Id).URL())) + "\n\n" + } else if req.State == "Proposed" && req.ResolutionTime.Before(time.Now()) { + // tx to resolve a request + action += md.Link("Resolve request", ufmt.Sprintf("%s", txlink.NewLink("ResolveRequest").AddArgs("id", req.Id).URL())) + "\n\n" + } + return action +} diff --git a/packages/r/intermarch3/goo/resolver.gno b/packages/r/intermarch3/goo/resolver.gno new file mode 100644 index 0000000..70aaa64 --- /dev/null +++ b/packages/r/intermarch3/goo/resolver.gno @@ -0,0 +1,108 @@ +// Package goo contains the resolver logic for the GOO. +// It supports both Yes/No and Numeric resolutions. +// It is used to determine the winning value of a dispute and reward the voters. +package goo + +import ( + "chain" + "chain/runtime" +) + +func resolve(id string) int64 { + dispute := getDispute(id) + request := getRequest(id) + + if dispute.NbResolvedVotes == 0 { + // If no one voted or reveal their vote, the proposed value wins by default + return request.ProposedValue + } + + if request.YesNoQuestion { + return resolveYesNo(dispute) + } + return resolveNumeric(dispute) +} + +// resolveYesNo determines the winning value for yes/no disputes based on the highest total token weight. +// If 0 votes or equal votes, the proposed value wins by default. +func resolveYesNo(dispute *Dispute) int64 { + var ( + winningValue int64 + weight int64 + yesVotes int64 + noVotes int64 + ) + + for _, vote := range dispute.Votes { + if vote.Revealed { + if vote.Value == 1 { + yesVotes += vote.TokenAmount + } else if vote.Value == 0 { + noVotes += vote.TokenAmount + } + } + } + + if yesVotes > noVotes { + winningValue = 1 + weight = yesVotes + } else { + winningValue = 0 + weight = noVotes + } + + rewardAndSlashVoters(dispute, winningValue, weight) + return winningValue +} + +// resolveNumeric determines the winning value for numeric disputes based on the highest total token weight. +// Not the best algorithm, but gas effective and fast. +func resolveNumeric(dispute *Dispute) int64 { + // Find the value with the most token weight + var ( + winningValue int64 + maxWeight int64 + ) + + // Collect unique values and aggregate weights + valueWeights := make(map[int64]int64) + for _, vote := range dispute.Votes { + if vote.Revealed { + valueWeights[vote.Value] += vote.TokenAmount + } + } + + for value, weight := range valueWeights { + if weight > maxWeight { + maxWeight = weight + winningValue = value + } + } + + rewardAndSlashVoters(dispute, winningValue, maxWeight) + return winningValue +} + +func rewardAndSlashVoters(dispute *Dispute, winningValue, totalWeight int64) { + for _, vote := range dispute.Votes { + if !vote.Revealed { + continue + } + + if vote.Value == winningValue { + // Reward winning voters + VoteToken.mint(vote.Voter, 2) + reward := bond * (vote.TokenAmount / totalWeight) + + if reward != 0 { + bank.SendCoins(runtime.CurrentRealm().Address(), vote.Voter, chain.Coins{chain.Coin{Denom: "ugnot", Amount: reward}}) + } + chain.Emit("VoterRewarded", "voter", vote.Voter.String()) + } else { + // Slash losing voters + slash := vote.TokenAmount / 4 // 25% slash + VoteToken.burn(vote.Voter, slash) + chain.Emit("VoterSlashed", "voter", vote.Voter.String()) + } + } +} diff --git a/packages/r/intermarch3/goo/token.gno b/packages/r/intermarch3/goo/token.gno new file mode 100644 index 0000000..51431fa --- /dev/null +++ b/packages/r/intermarch3/goo/token.gno @@ -0,0 +1,27 @@ +package goo + +import ( + "gno.land/p/demo/tokens/grc20" +) + +func newOOToken(name, symbol string, decimals int) *OOT { + token := &OOT{} + t, adm := grc20.NewToken(name, symbol, decimals) + token.Token = t + token.PrivateLedger = adm + return token +} + +// Mint create new tokens to an address. +func (t *OOT) mint(to address, amount int64) { + t.PrivateLedger.Mint(to, amount) +} + +// Burn destroy tokens from an address. +func (t *OOT) burn(from address, amount int64) { + t.PrivateLedger.Burn(from, amount) +} + +func (t *OOT) BalanceOf(addr address) int64 { + return t.Token.BalanceOf(addr) +} diff --git a/packages/r/intermarch3/goo/types.gno b/packages/r/intermarch3/goo/types.gno new file mode 100644 index 0000000..7c34491 --- /dev/null +++ b/packages/r/intermarch3/goo/types.gno @@ -0,0 +1,58 @@ +package goo + +import ( + "time" + + "gno.land/p/demo/tokens/grc20" + "gno.land/p/nt/avl" +) + +// court.gno +type Vote struct { + RequestId string + Voter address + TokenAmount int64 + Hash string + Value int64 + Revealed bool +} + +type Voter struct { + HasVoted bool + VoteIndex int64 +} + +type Dispute struct { + RequestId string + Votes []Vote + NbResolvedVotes int64 + Voters *avl.Tree + IsResolved bool + WinningValue int64 + EndTime time.Time + EndRevealTime time.Time +} + +// oracle.gno +type DataRequest struct { + Id string + Creator address + Timestamp time.Time + AncillaryData string + YesNoQuestion bool + ProposedValue int64 + Proposer address + ProposerBond int64 + Disputer address + DisputerBond int64 + ResolutionTime time.Time + WinningValue int64 + State string // "Requested", "Proposed", "Disputed", "Resolved" + Deadline time.Time +} + +// token.gno +type OOT struct { + *grc20.Token + *grc20.PrivateLedger +}