Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Go CI

on:
pull_request:
paths:
- "go/**"
- ".github/workflows/go.yml"
push:
branches: [main]
paths:
- "go/**"
- ".github/workflows/go.yml"

jobs:
test:
name: Go ${{ matrix.go-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.21", "1.22", "1.23"]
defaults:
run:
working-directory: go
steps:
- uses: actions/checkout@v5

- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache-dependency-path: go/go.sum

- name: go mod download
run: go mod download

- name: go vet
run: go vet ./...

- name: go build
run: go build ./...

- name: go test
run: go test -race -count=1 ./...
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This is a monorepo with one package per language, each designed as a **plugin**
| ---------- | -------------------------------------------------------------------------- | ------------------------------------------------------ |
| TypeScript | [`@acedatacloud/x402-client`](./typescript) — npm | [`@acedatacloud/sdk`](https://github.com/AceDataCloud/SDK) |
| Python | [`acedatacloud-x402`](./python) — PyPI | [`acedatacloud`](https://pypi.org/project/acedatacloud/) |
| Go | [`github.com/AceDataCloud/X402Client/go`](./go) — go module (tag-based) | [`github.com/AceDataCloud/SDK/go`](https://github.com/AceDataCloud/SDK) |

The SDK does all the API work (task polling, SSE streaming, retries, typed errors). This package only contributes one thing: signing an `X-Payment` header when the server returns `402 Payment Required`.

Expand Down
123 changes: 123 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# X402Client Go package

Go implementation of the [AceDataCloud x402 payment client](https://github.com/AceDataCloud/X402Client).

Mirrors the [TypeScript](../typescript) and [Python](../python) packages:
sign an `X-Payment` header for HTTP `402 Payment Required` responses from
AceDataCloud, using on-chain USDC instead of an API token.

Supports:

- 🟦 **Base** — USDC (ERC-20) via EIP-3009 `TransferWithAuthorization`
- 🟪 **Solana** — SPL USDC via signed `TransferChecked`
- 🟨 **SKALE** — USDC (bridged) via EIP-3009

Settlement happens through [`facilitator.acedata.cloud`](https://github.com/AceDataCloud/FacilitatorX402).

## Install

```bash
go get github.com/AceDataCloud/X402Client/go@latest
```

## Quick start — Base / SKALE (EVM)

```go
package main

import (
"context"
"log"

x402 "github.com/AceDataCloud/X402Client/go"
)

func main() {
signer, err := x402.NewEVMSignerFromPrivateKey("0x<your-private-key>")
if err != nil { log.Fatal(err) }

handler, err := x402.NewHandler(x402.HandlerOptions{
Network: x402.NetworkBase, // or NetworkSKALE
EVMSigner: signer,
})
if err != nil { log.Fatal(err) }

// Feed the handler the `accepts` list from a 402 response:
header, err := handler.Sign(context.Background(), accepts)
// ...attach header as the "X-Payment" HTTP header and retry.
_ = header
}
```

## Quick start — Solana

```go
signer, err := x402.NewSolanaSignerFromBase58("<base58-secret>")
handler, _ := x402.NewHandler(x402.HandlerOptions{
Network: x402.NetworkSolana,
SolanaSigner: signer,
// RPCURL: "https://your-rpc.example", // optional override
})
```

The Solana flow submits the `TransferChecked` transaction to the network
itself and returns the on-chain signature inside the X-Payment envelope.

## Plugging into the AceDataCloud Go SDK

The [`acedatacloud` Go SDK](https://github.com/AceDataCloud/SDK) exposes
a `PaymentHandler` interface that is invoked on 402 responses. Adapter:

```go
import (
"context"

acedatacloud "github.com/AceDataCloud/SDK/go"
x402 "github.com/AceDataCloud/X402Client/go"
)

signer, _ := x402.NewEVMSignerFromPrivateKey(privateKey)
x402Handler, _ := x402.NewHandler(x402.HandlerOptions{
Network: x402.NetworkBase,
EVMSigner: signer,
})

// Bridge x402.Handler → acedatacloud.PaymentHandler (tiny adapter).
bridge := acedatacloud.PaymentHandlerFunc(func(ctx context.Context, pctx acedatacloud.PaymentContext) (acedatacloud.PaymentResult, error) {
accepts := make([]x402.PaymentRequirement, 0, len(pctx.Accepts))
for _, a := range pctx.Accepts {
accepts = append(accepts, x402.PaymentRequirementFromMap(a))
}
headers, err := x402Handler.Headers(ctx, accepts)
if err != nil { return acedatacloud.PaymentResult{}, err }
return acedatacloud.PaymentResult{Headers: headers}, nil
})

client, _ := acedatacloud.NewClient(acedatacloud.WithPaymentHandler(bridge))
```

## Testing

```bash
cd go
go vet ./...
go test ./...
```

Unit tests verify:

- EIP-712 digest round-trip (signature recovers to the signer's address)
- Default `USD Coin / v2 / chainId=8453` EIP-712 domain matches the TS/Python reference
- Handler validation (missing signer, unsupported network)
- Solana ATA derivation and instruction encoding shapes
- Requirement selection logic

## Live on-chain E2E script

The `scripts/e2e` binary hits a real AceDataCloud endpoint and
completes a SKALE / Base payment end-to-end. Requires USDC balance.

```bash
cd go
SKALE_BASE_PRIVATE_KEY=0x... go run ./scripts/e2e
```
12 changes: 12 additions & 0 deletions go/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Package x402 is the Go implementation of the AceDataCloud x402 payment
// client. It mirrors the TypeScript (`@acedatacloud/x402-client`) and
// Python (`acedatacloud-x402`) packages.
//
// It signs an ``X-Payment`` header for HTTP 402 responses using either
// EIP-3009 ``TransferWithAuthorization`` (for EVM chains such as Base
// and SKALE) or an SPL ``TransferChecked`` transaction (for Solana).
//
// The package is designed to plug into the AceDataCloud Go SDK
// (``github.com/AceDataCloud/SDK/go``) as a ``PaymentHandler``. See the
// README for the 4-line adapter snippet.
package x402
207 changes: 207 additions & 0 deletions go/evm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package x402

import (
"crypto/ecdsa"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"math/big"
"strings"
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/math"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/signer/core/apitypes"
)

// EVMSigner signs EIP-3009 ``TransferWithAuthorization`` messages with a
// locally-held ECDSA private key. Use NewEVMSignerFromPrivateKey to
// construct one.
type EVMSigner struct {
privateKey *ecdsa.PrivateKey
address common.Address
}

// NewEVMSignerFromPrivateKey builds a signer from a hex-encoded secp256k1
// private key. The ``0x`` prefix is optional.
func NewEVMSignerFromPrivateKey(pkHex string) (*EVMSigner, error) {
pkHex = strings.TrimPrefix(pkHex, "0x")
pk, err := crypto.HexToECDSA(pkHex)
if err != nil {
return nil, fmt.Errorf("invalid EVM private key: %w", err)
}
return &EVMSigner{privateKey: pk, address: crypto.PubkeyToAddress(pk.PublicKey)}, nil
}

// Address returns the signer's checksummed 0x address.
func (s *EVMSigner) Address() string { return s.address.Hex() }

// randomNonce32 returns a 0x-prefixed random 32-byte nonce.
func randomNonce32() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return "0x" + hex.EncodeToString(b[:]), nil
}

// SignEVMPayment builds and signs a TransferWithAuthorization envelope
// for the given payment requirement. The returned envelope should be
// JSON-encoded and base64-encoded into the ``X-Payment`` header.
func SignEVMPayment(req PaymentRequirement, signer *EVMSigner) (*X402PaymentEnvelope, error) {
if signer == nil {
return nil, errors.New("x402: evm signer is nil")
}
if req.MaxAmountRequired == "" {
return nil, errors.New("x402: maxAmountRequired is required")
}
value, ok := new(big.Int).SetString(req.MaxAmountRequired, 10)
if !ok {
return nil, fmt.Errorf("x402: invalid maxAmountRequired %q", req.MaxAmountRequired)
}
maxTimeout := req.MaxTimeoutSeconds
if maxTimeout <= 0 {
maxTimeout = 120
}
now := time.Now().Unix()
nonce, err := randomNonce32()
if err != nil {
return nil, err
}
auth := EVMAuthorization{
From: signer.Address(),
To: req.PayTo,
Value: value.String(),
ValidAfter: fmt.Sprintf("%d", now),
ValidBefore: fmt.Sprintf("%d", now+int64(maxTimeout)),
Nonce: nonce,
}

typedData, err := buildTypedData(req, auth)
if err != nil {
return nil, err
}
sig, err := signTypedData(signer.privateKey, typedData)
if err != nil {
return nil, fmt.Errorf("x402: EIP-712 sign: %w", err)
}

envelope := &X402PaymentEnvelope{
X402Version: 2,
Scheme: firstNonEmpty(req.Scheme, "exact"),
Network: firstNonEmpty(req.Network, string(NetworkBase)),
Payload: EVMPayload{Authorization: auth, Signature: "0x" + hex.EncodeToString(sig)},
}
return envelope, nil
}

// buildTypedData mirrors typescript/src/evm.ts buildTypedData.
func buildTypedData(req PaymentRequirement, auth EVMAuthorization) (apitypes.TypedData, error) {
extra := req.Extra
if extra == nil {
extra = map[string]any{}
}

domainName, _ := extra["name"].(string)
if domainName == "" {
domainName = "USD Coin"
}
domainVersion, _ := extra["version"].(string)
if domainVersion == "" {
domainVersion = "2"
}

// chainId may arrive as float64 (from JSON) or int.
var chainID *big.Int
switch v := extra["chainId"].(type) {
case float64:
chainID = big.NewInt(int64(v))
case int:
chainID = big.NewInt(int64(v))
case int64:
chainID = big.NewInt(v)
case string:
chainID, _ = new(big.Int).SetString(v, 10)
}
if chainID == nil {
chainID = big.NewInt(8453) // Base mainnet default, matches TS.
}

verifyingContract, _ := extra["verifyingContract"].(string)
if verifyingContract == "" {
verifyingContract = req.Asset
}
if !common.IsHexAddress(verifyingContract) {
return apitypes.TypedData{}, fmt.Errorf("x402: invalid verifyingContract %q", verifyingContract)
}

return apitypes.TypedData{
Types: apitypes.Types{
"EIP712Domain": []apitypes.Type{
{Name: "name", Type: "string"},
{Name: "version", Type: "string"},
{Name: "chainId", Type: "uint256"},
{Name: "verifyingContract", Type: "address"},
},
"TransferWithAuthorization": []apitypes.Type{
{Name: "from", Type: "address"},
{Name: "to", Type: "address"},
{Name: "value", Type: "uint256"},
{Name: "validAfter", Type: "uint256"},
{Name: "validBefore", Type: "uint256"},
{Name: "nonce", Type: "bytes32"},
},
},
PrimaryType: "TransferWithAuthorization",
Domain: apitypes.TypedDataDomain{
Name: domainName,
Version: domainVersion,
ChainId: math.NewHexOrDecimal256(chainID.Int64()),
VerifyingContract: common.HexToAddress(verifyingContract).Hex(),
},
Message: apitypes.TypedDataMessage{
"from": auth.From,
"to": auth.To,
"value": auth.Value,
"validAfter": auth.ValidAfter,
"validBefore": auth.ValidBefore,
"nonce": auth.Nonce,
},
}, nil
}

// signTypedData produces the 65-byte (R||S||V) EIP-712 signature. V is
// normalized to 27/28 to match Ethereum's canonical form, which is what
// the facilitator's ecrecover implementation expects.
func signTypedData(privKey *ecdsa.PrivateKey, typedData apitypes.TypedData) ([]byte, error) {
domainSeparator, err := typedData.HashStruct("EIP712Domain", typedData.Domain.Map())
if err != nil {
return nil, fmt.Errorf("hash domain: %w", err)
}
messageHash, err := typedData.HashStruct(typedData.PrimaryType, typedData.Message)
if err != nil {
return nil, fmt.Errorf("hash message: %w", err)
}
raw := append([]byte{0x19, 0x01}, domainSeparator...)
raw = append(raw, messageHash...)
digest := crypto.Keccak256(raw)

sig, err := crypto.Sign(digest, privKey)
if err != nil {
return nil, err
}
// crypto.Sign returns V as 0 or 1; canonical EIP-712 uses 27 or 28.
sig[64] += 27
return sig, nil
}

func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
Loading
Loading