diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..2a44f1e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,33 @@ +# Aptos SDK Examples + +Standalone sample applications that demonstrate Aptos SDK features across multiple languages. Each +example is self-contained, runnable, and designed to be a starting point you can expand. + +## Examples + +| Example | Description | Languages | +| ----------------------------------- | ------------------------------------------ | -------------- | +| [batch-transfer](./batch-transfer/) | Send N transactions in parallel with retry | TypeScript, Go | + +## Design Principles + +Each example: + +- **Runs end-to-end** against devnet (no mocks) +- **Shows real patterns** (sequence number management, gas estimation, retry) +- **Has configurable parameters** (transaction count, network) +- **Produces structured output** (JSON summary for easy comparison across SDKs) + +## Adding a New Example + +1. Create `examples//` directory +2. Add `examples//README.md` explaining the scenario +3. Implement in each target language under `examples///` +4. Each language directory must be runnable with a single command + +## Adding a New Language to an Existing Example + +1. Create `examples///` +2. Follow the same 4-phase structure: Setup → Batch Submit → Track & Confirm → Verify & Report +3. Use the same CLI flags (`--count`, `--network`) for consistency +4. Output the same JSON summary schema for cross-SDK comparison diff --git a/examples/batch-transfer/README.md b/examples/batch-transfer/README.md new file mode 100644 index 0000000..ab882cf --- /dev/null +++ b/examples/batch-transfer/README.md @@ -0,0 +1,115 @@ +# Batch Transfer Example + +Demonstrates sending **N APT transfers in parallel** using a single funded sender account. + +This example showcases: + +- Account generation and faucet funding +- Gas price estimation +- **Sequence number pre-fetching** — fetch once, increment locally (avoids N round-trips) +- Building and signing all transactions **locally** before any network calls +- Parallel submission via goroutines / Promise.allSettled +- Retry with exponential backoff on confirmation +- Structured JSON output for cross-SDK comparison + +## The Core Pattern: Sequence Number Management + +The naive approach to batch transactions fetches the account's sequence number _per transaction_: + +``` +for each tx: + fetch seq_num from chain ← N network round-trips! + build tx with seq_num + submit +``` + +This example uses the efficient approach: + +``` +fetch seq_num from chain once +for each tx (local, no network): + build tx with (seq_num + i) + sign tx +submit all in parallel +``` + +This reduces N round-trips to 1 and enables true parallel submission. + +## Implementations + +| Language | Directory | Command | +| ---------- | ---------------------------- | ----------------- | +| TypeScript | [typescript/](./typescript/) | `bun src/main.ts` | +| Go | [go/](./go/) | `go run main.go` | + +## Expected Output + +``` +Aptos Batch Transfer Example (TypeScript) +========================================== +Network: devnet +Transactions: 10 + +[1/4] Setup + ✓ Generated sender: 0x3f2a... + ✓ Generated 10 recipient accounts + ✓ Funded sender with 1 APT (tx: 0xabc...) + ✓ Sender balance: 100,000,000 octas + +[2/4] Batch Submission (10 transactions) + ✓ Gas price estimate: 100 octas/gas + ✓ Starting sequence number: 1 + ✓ Built & signed 10 transactions locally in 45ms + ✓ Submitted 10/10 in 312ms + +[3/4] Tracking Confirmations + ✓ Confirmed: 10/10 + +[4/4] Verify & Report + ✓ Final balance: 99,978,000 octas + ✓ Total spent (transfers + gas): 22,000 octas + +=== Summary === +{ + "network": "devnet", + "timestamp": "2026-02-21T12:00:00Z", + "transactions": { + "requested": 10, + "submitted": 10, + "confirmed": 10, + "failed": 0 + }, + "performance": { + "build_and_sign_ms": 45, + "submit_ms": 312 + }, + "economics": { + "initial_balance_octas": 100000000, + "final_balance_octas": 99978000, + "total_spent_octas": 22000, + "avg_cost_per_tx_octas": 2200 + } +} +``` + +## CLI Options + +``` +--count N Number of transactions to send (default: 10) +--network NAME Network: devnet or testnet (default: devnet) +``` + +> **Note:** `mainnet` is intentionally unsupported. This example generates a fresh sender account +> and funds it via the devnet/testnet faucet, which does not exist on mainnet. To extend this +> example for mainnet, accept a pre-funded sender private key via an environment variable and skip +> the faucet step. + +## Extending This Example + +This is intentionally simple to serve as a starting point. Consider adding: + +- **Simulate before submit**: Call the simulation API to get exact gas estimates +- **Retry on sequence number error**: Refetch and rebuild if a tx is rejected for invalid seq num +- **Concurrent senders**: Split N transactions across M sender accounts for higher throughput +- **Token transfers**: Replace APT coin transfers with fungible asset or NFT transfers +- **Wait strategies**: Implement polling vs. long-poll vs. webhook confirmation patterns diff --git a/examples/batch-transfer/go/go.mod b/examples/batch-transfer/go/go.mod new file mode 100644 index 0000000..61343ed --- /dev/null +++ b/examples/batch-transfer/go/go.mod @@ -0,0 +1,16 @@ +module github.com/aptos-labs/aptos-sdk-examples/batch-transfer + +go 1.24.0 + +require github.com/aptos-labs/aptos-go-sdk v1.11.0 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hasura/go-graphql-client v0.14.4 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/sys v0.38.0 // indirect +) diff --git a/examples/batch-transfer/go/go.sum b/examples/batch-transfer/go/go.sum new file mode 100644 index 0000000..fb2483e --- /dev/null +++ b/examples/batch-transfer/go/go.sum @@ -0,0 +1,44 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/aptos-labs/aptos-go-sdk v1.11.0 h1:vIL1hpjECUiu7zMl9Wz6VV8ttXsrDqKUj0HxoeaIER4= +github.com/aptos-labs/aptos-go-sdk v1.11.0/go.mod h1:8YvYwRg93UcG6pTStCpZdYiscCtKh51sYfeLgIy/41c= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= +github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +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/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +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/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= +github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hasura/go-graphql-client v0.14.4 h1:bYU7/+V50T2YBGdNQXt6l4f2cMZPECPUd8cyCR+ixtw= +github.com/hasura/go-graphql-client v0.14.4/go.mod h1:jfSZtBER3or+88Q9vFhWHiFMPppfYILRyl+0zsgPIIw= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +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/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/batch-transfer/go/main.go b/examples/batch-transfer/go/main.go new file mode 100644 index 0000000..e3523f4 --- /dev/null +++ b/examples/batch-transfer/go/main.go @@ -0,0 +1,378 @@ +// Aptos Batch Transfer Example - Go +// +// Demonstrates sending N APT transfers in parallel using: +// - Single sequence number fetch (not per-transaction) +// - Local RawTransaction build + sign before any submission +// - Goroutines + sync.WaitGroup for parallel submit +// - Exponential backoff retry on confirmation +// +// Usage: +// +// go run main.go [--count N] [--network devnet|testnet] +package main + +import ( + "encoding/json" + "flag" + "fmt" + "math" + "os" + "sync" + "time" + + aptos "github.com/aptos-labs/aptos-go-sdk" + "github.com/aptos-labs/aptos-go-sdk/api" +) + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const ( + transferAmountOctas = uint64(100) // 100 octas per transfer + fundAmountOctas = uint64(100_000_000) // 1 APT + maxGasAmount = uint64(10_000) + txExpirationOffsetSecs = uint64(600) // 10 minutes from now +) + +// ─── Report types ──────────────────────────────────────────────────────────── + +type report struct { + Network string `json:"network"` + Timestamp string `json:"timestamp"` + Transactions txStats `json:"transactions"` + Performance perfStats `json:"performance"` + Economics econStats `json:"economics"` +} + +type txStats struct { + Requested int `json:"requested"` + Submitted int `json:"submitted"` + Confirmed int `json:"confirmed"` + Failed int `json:"failed"` +} + +type perfStats struct { + BuildAndSignMs int64 `json:"build_and_sign_ms"` + SubmitMs int64 `json:"submit_ms"` +} + +type econStats struct { + InitialBalanceOctas uint64 `json:"initial_balance_octas"` + FinalBalanceOctas uint64 `json:"final_balance_octas"` + TotalSpentOctas uint64 `json:"total_spent_octas"` + AvgCostPerTxOctas uint64 `json:"avg_cost_per_tx_octas"` +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func networkConfig(name string) aptos.NetworkConfig { + if name == "testnet" { + return aptos.TestnetConfig + } + return aptos.DevnetConfig +} + +// submitWithRetry submits a signed transaction, retrying with exponential +// backoff if the submission fails (e.g. due to transient network errors). +func submitWithRetry( + client *aptos.Client, + signedTx *aptos.SignedTransaction, + maxAttempts int, + baseDelayMs int, +) (*api.SubmitTransactionResponse, error) { + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + if attempt > 0 { + delay := time.Duration(float64(baseDelayMs)*math.Pow(2, float64(attempt-1))) * time.Millisecond + time.Sleep(delay) + } + resp, err := client.SubmitTransaction(signedTx) + if err == nil { + return resp, nil + } + lastErr = err + } + return nil, lastErr +} + +// waitWithRetry polls for transaction confirmation, retrying with exponential +// backoff if the wait times out. +func waitWithRetry( + client *aptos.Client, + hash string, + maxAttempts int, + baseDelayMs int, +) error { + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + if attempt > 0 { + delay := time.Duration(float64(baseDelayMs)*math.Pow(2, float64(attempt-1))) * time.Millisecond + time.Sleep(delay) + } + _, err := client.WaitForTransaction(hash) + if err == nil { + return nil + } + lastErr = err + } + return lastErr +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +func main() { + count := flag.Int("count", 10, "Number of transactions to send") + networkName := flag.String("network", "devnet", "Network: devnet or testnet") + flag.Parse() + + if *count < 1 { + fmt.Fprintf(os.Stderr, "Invalid value for --count: %d (must be >= 1)\n", *count) + os.Exit(1) + } + if *networkName == "mainnet" { + fmt.Fprintln(os.Stderr, "Error: mainnet is not supported by this example because it funds a new account via faucet.") + fmt.Fprintln(os.Stderr, "To use mainnet, extend this example to accept a pre-funded sender key.") + os.Exit(1) + } + if *networkName != "devnet" && *networkName != "testnet" { + fmt.Fprintf(os.Stderr, "Error: unknown network %q. Allowed values: devnet, testnet\n", *networkName) + os.Exit(1) + } + + client, err := aptos.NewClient(networkConfig(*networkName)) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create client: %v\n", err) + os.Exit(1) + } + + fmt.Println("\nAptos Batch Transfer Example (Go)") + fmt.Println("===================================") + fmt.Printf("Network: %s\n", *networkName) + fmt.Printf("Transactions: %d\n\n", *count) + + // ═════════════════════════════════════════════════════════════════════════ + // Phase 1: Setup + // ═════════════════════════════════════════════════════════════════════════ + fmt.Println("[1/4] Setup") + + sender, err := aptos.NewEd25519Account() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to generate sender: %v\n", err) + os.Exit(1) + } + fmt.Printf(" ✓ Generated sender: %s\n", sender.Address.String()) + + recipients := make([]*aptos.Account, *count) + for i := range recipients { + r, err := aptos.NewEd25519Account() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to generate recipient %d: %v\n", i, err) + os.Exit(1) + } + recipients[i] = r + } + fmt.Printf(" ✓ Generated %d recipient accounts\n", *count) + + if err := client.Fund(sender.Address, fundAmountOctas); err != nil { + fmt.Fprintf(os.Stderr, "Failed to fund sender: %v\n", err) + os.Exit(1) + } + fmt.Printf(" ✓ Funded sender with 1 APT\n") + + initialBalance, err := client.AccountAPTBalance(sender.Address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get balance: %v\n", err) + os.Exit(1) + } + fmt.Printf(" ✓ Sender balance: %d octas\n\n", initialBalance) + + // ═════════════════════════════════════════════════════════════════════════ + // Phase 2: Batch Submission + // ═════════════════════════════════════════════════════════════════════════ + fmt.Printf("[2/4] Batch Submission (%d transactions)\n", *count) + + // Estimate gas price once for all transactions + gasInfo, err := client.EstimateGasPrice() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to estimate gas: %v\n", err) + os.Exit(1) + } + gasUnitPrice := gasInfo.GasEstimate + fmt.Printf(" ✓ Gas price estimate: %d octas/gas\n", gasUnitPrice) + + // Fetch chain ID from ledger info + ledgerInfo, err := client.Info() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get ledger info: %v\n", err) + os.Exit(1) + } + chainID := ledgerInfo.ChainId + + // KEY INSIGHT: Fetch the sequence number ONCE, then increment locally. + // Without this, each transaction build would call the chain for the seq num, + // causing N round-trips and making parallel submission impossible. + accountInfo, err := client.Account(sender.Address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get account info: %v\n", err) + os.Exit(1) + } + startSeqNum, err := accountInfo.SequenceNumber() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get sequence number: %v\n", err) + os.Exit(1) + } + fmt.Printf(" ✓ Starting sequence number: %d\n", startSeqNum) + + // Build and sign all transactions locally (zero network calls per-transaction) + t0 := time.Now() + signedTxns := make([]*aptos.SignedTransaction, *count) + expiration := uint64(time.Now().Unix()) + txExpirationOffsetSecs + + for i := 0; i < *count; i++ { + payload, err := aptos.CoinTransferPayload(nil, recipients[i].Address, transferAmountOctas) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to build payload %d: %v\n", i, err) + os.Exit(1) + } + + rawTx := &aptos.RawTransaction{ + Sender: sender.Address, + SequenceNumber: startSeqNum + uint64(i), + Payload: aptos.TransactionPayload{Payload: payload}, + MaxGasAmount: maxGasAmount, + GasUnitPrice: gasUnitPrice, + ExpirationTimestampSeconds: expiration, + ChainId: chainID, + } + + signedTx, err := rawTx.SignedTransaction(sender) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to sign tx %d: %v\n", i, err) + os.Exit(1) + } + signedTxns[i] = signedTx + } + buildMs := time.Since(t0).Milliseconds() + fmt.Printf(" ✓ Built & signed %d transactions locally in %dms\n", *count, buildMs) + + // Submit all transactions in parallel using goroutines + type submitResult struct { + hash string + err error + } + submitResults := make([]submitResult, *count) + + t1 := time.Now() + var wg sync.WaitGroup + for i := 0; i < *count; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + resp, err := submitWithRetry(client, signedTxns[idx], 3, 500) + if err != nil { + submitResults[idx] = submitResult{err: err} + return + } + submitResults[idx] = submitResult{hash: resp.Hash} + }(i) + } + wg.Wait() + submitMs := time.Since(t1).Milliseconds() + + var pendingHashes []string + submitFailed := 0 + for _, r := range submitResults { + if r.err != nil { + submitFailed++ + } else { + pendingHashes = append(pendingHashes, r.hash) + } + } + + fmt.Printf(" ✓ Submitted %d/%d in %dms\n", len(pendingHashes), *count, submitMs) + if submitFailed > 0 { + fmt.Printf(" ⚠ %d submission failure(s)\n", submitFailed) + } + fmt.Println() + + // ═════════════════════════════════════════════════════════════════════════ + // Phase 3: Track & Confirm + // ═════════════════════════════════════════════════════════════════════════ + fmt.Println("[3/4] Tracking Confirmations") + + var mu sync.Mutex + confirmed := 0 + confirmFailed := 0 + + var confirmWg sync.WaitGroup + for _, hash := range pendingHashes { + confirmWg.Add(1) + go func(h string) { + defer confirmWg.Done() + err := waitWithRetry(client, h, 3, 2_000) + mu.Lock() + defer mu.Unlock() + if err == nil { + confirmed++ + } else { + confirmFailed++ + } + }(hash) + } + confirmWg.Wait() + + fmt.Printf(" ✓ Confirmed: %d/%d\n", confirmed, len(pendingHashes)) + if confirmFailed > 0 { + fmt.Printf(" ✗ Failed to confirm: %d\n", confirmFailed) + } + fmt.Println() + + // ═════════════════════════════════════════════════════════════════════════ + // Phase 4: Verify & Report + // ═════════════════════════════════════════════════════════════════════════ + fmt.Println("[4/4] Verify & Report") + + finalBalance, err := client.AccountAPTBalance(sender.Address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get final balance: %v\n", err) + os.Exit(1) + } + + var totalSpent, avgCost uint64 + if finalBalance <= initialBalance { + totalSpent = initialBalance - finalBalance + } + if confirmed > 0 { + avgCost = totalSpent / uint64(confirmed) + } + + fmt.Printf(" ✓ Final balance: %d octas\n", finalBalance) + fmt.Printf(" ✓ Total spent (transfers + gas): %d octas\n", totalSpent) + + summary := report{ + Network: *networkName, + Timestamp: time.Now().Format(time.RFC3339), + Transactions: txStats{ + Requested: *count, + Submitted: len(pendingHashes), + Confirmed: confirmed, + Failed: submitFailed + confirmFailed, + }, + Performance: perfStats{ + BuildAndSignMs: buildMs, + SubmitMs: submitMs, + }, + Economics: econStats{ + InitialBalanceOctas: initialBalance, + FinalBalanceOctas: finalBalance, + TotalSpentOctas: totalSpent, + AvgCostPerTxOctas: avgCost, + }, + } + + jsonBytes, err := json.MarshalIndent(summary, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to marshal summary to JSON: %v\n", err) + os.Exit(1) + } + fmt.Printf("\n=== Summary ===\n%s\n", jsonBytes) +} diff --git a/examples/batch-transfer/typescript/bun.lock b/examples/batch-transfer/typescript/bun.lock new file mode 100644 index 0000000..09c5901 --- /dev/null +++ b/examples/batch-transfer/typescript/bun.lock @@ -0,0 +1,101 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "aptos-batch-transfer-example", + "dependencies": { + "@aptos-labs/ts-sdk": "^5.2.0", + }, + }, + }, + "packages": { + "@aptos-labs/aptos-cli": ["@aptos-labs/aptos-cli@1.1.1", "", { "dependencies": { "commander": "^12.1.0" }, "bin": { "aptos": "dist/aptos.js" } }, "sha512-sB7CokCM6s76SLJmccysbnFR+MDik6udKfj2+9ZsmTLV0/t73veIeCDKbvWJmbW267ibx4HiGbPI7L+1+yjEbQ=="], + + "@aptos-labs/aptos-client": ["@aptos-labs/aptos-client@2.1.0", "", { "peerDependencies": { "got": "^11.8.6" } }, "sha512-ttdY0qclRvbYAAwzijkFeipuqTfLFJnoXlNIm58tIw3DKhIlfYdR6iLqTeCpI23oOPghnO99FZecej/0MTrtuA=="], + + "@aptos-labs/ts-sdk": ["@aptos-labs/ts-sdk@5.2.1", "", { "dependencies": { "@aptos-labs/aptos-cli": "^1.0.2", "@aptos-labs/aptos-client": "^2.1.0", "@noble/curves": "^1.9.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.4.0", "@scure/bip39": "^1.3.0", "eventemitter3": "^5.0.1", "js-base64": "^3.7.7", "jwt-decode": "^4.0.0", "poseidon-lite": "^0.2.0" } }, "sha512-kazYjqfsPCBx2UJI+nYUOb6Ov7q7brSgYEfxp2sP27IeJWdDNa50lfs0WIpDJ92kQxdtlm9q3ZWw7Toh9f1gxQ=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + + "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], + + "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + + "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + + "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + + "poseidon-lite": ["poseidon-lite@0.2.1", "", {}, "sha512-xIr+G6HeYfOhCuswdqcFpSX47SPhm0EpisWJ6h7fHlWwaVIvH3dLnejpatrtw6Xc6HaLrpq05y7VRfvDmDGIog=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + + "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + + "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], + } +} diff --git a/examples/batch-transfer/typescript/package.json b/examples/batch-transfer/typescript/package.json new file mode 100644 index 0000000..34d0e04 --- /dev/null +++ b/examples/batch-transfer/typescript/package.json @@ -0,0 +1,13 @@ +{ + "name": "aptos-batch-transfer-example", + "version": "1.0.0", + "description": "Batch APT transfer example demonstrating parallel transaction submission", + "type": "module", + "scripts": { + "start": "bun src/main.ts", + "start:testnet": "bun src/main.ts --network testnet" + }, + "dependencies": { + "@aptos-labs/ts-sdk": "^5.2.0" + } +} diff --git a/examples/batch-transfer/typescript/src/main.ts b/examples/batch-transfer/typescript/src/main.ts new file mode 100644 index 0000000..9999d01 --- /dev/null +++ b/examples/batch-transfer/typescript/src/main.ts @@ -0,0 +1,277 @@ +/** + * Aptos Batch Transfer Example - TypeScript + * + * Demonstrates sending N APT transfers in parallel using: + * - Single sequence number fetch (not per-transaction) + * - Local build + sign before any submission + * - Promise.allSettled for parallel submit + * - Exponential backoff retry on confirmation + * + * Usage: + * bun src/main.ts [--count N] [--network devnet|testnet] + */ + +import { + Account, + AccountAuthenticator, + Aptos, + AptosConfig, + Network, + SimpleTransaction, +} from "@aptos-labs/ts-sdk"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const COIN_STORE = "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>"; +const TRANSFER_AMOUNT_OCTAS = 100; // small amount per transfer +const FUND_AMOUNT_OCTAS = 100_000_000; // 1 APT +const MAX_GAS_AMOUNT = 10_000; + +// ─── CLI Parsing ────────────────────────────────────────────────────────────── + +function parseArgs(): { count: number; network: string } { + const args = process.argv.slice(2); + let count = 10; + let network = "devnet"; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--count" && i + 1 < args.length) { + count = parseInt(args[i + 1], 10); + if (isNaN(count) || count < 1) { + console.error("--count must be a positive integer"); + process.exit(1); + } + i++; // consume the value token + } else if (args[i] === "--network" && i + 1 < args.length) { + network = args[i + 1]; + if (network === "mainnet") { + console.error( + "Error: mainnet is not supported by this example because it funds a new account via faucet.", + ); + console.error("To use mainnet, extend this example to accept a pre-funded sender key."); + process.exit(1); + } + if (!["devnet", "testnet"].includes(network)) { + console.error(`Error: unknown network "${network}". Allowed values: devnet, testnet`); + process.exit(1); + } + i++; // consume the value token + } + } + return { count, network }; +} + +function toNetworkEnum(name: string): Network { + return name === "testnet" ? Network.TESTNET : Network.DEVNET; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function getBalanceOctas(aptos: Aptos, address: Account["accountAddress"]): Promise { + try { + const resource = await aptos.getAccountResource({ + accountAddress: address, + resourceType: COIN_STORE as `${string}::${string}::${string}`, + }); + return Number((resource as { coin: { value: string } }).coin.value); + } catch { + return 0; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Waits for a submitted transaction to be confirmed, retrying with + * exponential backoff if the wait times out. + */ +async function waitWithRetry( + aptos: Aptos, + hash: string, + maxAttempts = 3, + baseDelayMs = 2_000, +): Promise<{ hash: string }> { + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + const delay = baseDelayMs * Math.pow(2, attempt - 1); + await sleep(delay); + } + try { + const result = await aptos.waitForTransaction({ + transactionHash: hash, + options: { timeoutSecs: 30, checkSuccess: true }, + }); + return { hash: result.hash }; + } catch (err) { + lastError = err; + } + } + throw lastError ?? new Error(`Failed to confirm ${hash} after ${maxAttempts} attempts`); +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const { count, network: networkName } = parseArgs(); + const aptos = new Aptos(new AptosConfig({ network: toNetworkEnum(networkName) })); + + console.log(`\nAptos Batch Transfer Example (TypeScript)`); + console.log(`==========================================`); + console.log(`Network: ${networkName}`); + console.log(`Transactions: ${count}`); + console.log(); + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 1: Setup + // ═══════════════════════════════════════════════════════════════════════════ + console.log(`[1/4] Setup`); + + const sender = Account.generate(); + console.log(` ✓ Generated sender: ${sender.accountAddress.toString()}`); + + const recipients: Account[] = Array.from({ length: count }, () => Account.generate()); + console.log(` ✓ Generated ${count} recipient accounts`); + + // Fund the sender via the devnet/testnet faucet + const fundResult = await aptos.fundAccount({ + accountAddress: sender.accountAddress, + amount: FUND_AMOUNT_OCTAS, + }); + await aptos.waitForTransaction({ transactionHash: fundResult.hash }); + console.log(` ✓ Funded sender with 1 APT (tx: ${fundResult.hash})`); + + const initialBalance = await getBalanceOctas(aptos, sender.accountAddress); + console.log(` ✓ Sender balance: ${initialBalance.toLocaleString()} octas`); + console.log(); + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 2: Batch Submission + // ═══════════════════════════════════════════════════════════════════════════ + console.log(`[2/4] Batch Submission (${count} transactions)`); + + // Estimate gas price once for all transactions + const gasEstimate = await aptos.getGasPriceEstimation(); + const gasUnitPrice = gasEstimate.gas_estimate; + console.log(` ✓ Gas price estimate: ${gasUnitPrice} octas/gas`); + + // KEY INSIGHT: Fetch the sequence number ONCE, then increment locally. + // Without this, each build.simple() would call the chain for the seq num, + // causing N round-trips and making parallel submission impossible. + const accountInfo = await aptos.getAccountInfo({ accountAddress: sender.accountAddress }); + const startSeqNum = BigInt(accountInfo.sequence_number); + console.log(` ✓ Starting sequence number: ${startSeqNum}`); + + // Build and sign all transactions locally (zero network calls per-transaction) + const t0 = Date.now(); + const prepared: Array<{ txn: SimpleTransaction; auth: AccountAuthenticator }> = []; + + for (let i = 0; i < count; i++) { + const txn = await aptos.transaction.build.simple({ + sender: sender.accountAddress, + data: { + function: "0x1::aptos_account::transfer", + functionArguments: [recipients[i].accountAddress, TRANSFER_AMOUNT_OCTAS], + }, + options: { + accountSequenceNumber: startSeqNum + BigInt(i), + gasUnitPrice, + maxGasAmount: MAX_GAS_AMOUNT, + }, + }); + const auth = aptos.transaction.sign({ signer: sender, transaction: txn }); + prepared.push({ txn, auth }); + } + + const buildMs = Date.now() - t0; + console.log(` ✓ Built & signed ${count} transactions locally in ${buildMs}ms`); + + // Submit all transactions in parallel + const t1 = Date.now(); + const submitResults = await Promise.allSettled( + prepared.map(({ txn, auth }) => + aptos.transaction.submit.simple({ + transaction: txn, + senderAuthenticator: auth, + }), + ), + ); + const submitMs = Date.now() - t1; + + const pendingHashes: string[] = []; + const submitErrors: string[] = []; + for (const result of submitResults) { + if (result.status === "fulfilled") { + pendingHashes.push(result.value.hash); + } else { + submitErrors.push(String(result.reason)); + } + } + + console.log(` ✓ Submitted ${pendingHashes.length}/${count} in ${submitMs}ms`); + if (submitErrors.length > 0) { + console.log(` ⚠ ${submitErrors.length} submission failure(s)`); + for (const e of submitErrors) console.log(` - ${e}`); + } + console.log(); + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 3: Track & Confirm + // ═══════════════════════════════════════════════════════════════════════════ + console.log(`[3/4] Tracking Confirmations`); + + const confirmResults = await Promise.allSettled( + pendingHashes.map((hash) => waitWithRetry(aptos, hash)), + ); + + const confirmed = confirmResults.filter((r) => r.status === "fulfilled").length; + const confirmFailed = confirmResults.filter((r) => r.status === "rejected").length; + + console.log(` ✓ Confirmed: ${confirmed}/${pendingHashes.length}`); + if (confirmFailed > 0) { + console.log(` ✗ Failed to confirm: ${confirmFailed}`); + } + console.log(); + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 4: Verify & Report + // ═══════════════════════════════════════════════════════════════════════════ + console.log(`[4/4] Verify & Report`); + + const finalBalance = await getBalanceOctas(aptos, sender.accountAddress); + const totalSpent = Math.max(0, initialBalance - finalBalance); + const avgCostPerTx = confirmed > 0 ? Math.round(totalSpent / confirmed) : 0; + + console.log(` ✓ Final balance: ${finalBalance.toLocaleString()} octas`); + console.log(` ✓ Total spent (transfers + gas): ${totalSpent.toLocaleString()} octas`); + + const report = { + network: networkName, + timestamp: new Date().toISOString(), + transactions: { + requested: count, + submitted: pendingHashes.length, + confirmed, + failed: submitErrors.length + confirmFailed, + }, + performance: { + build_and_sign_ms: buildMs, + submit_ms: submitMs, + }, + economics: { + initial_balance_octas: initialBalance, + final_balance_octas: finalBalance, + total_spent_octas: totalSpent, + avg_cost_per_tx_octas: avgCostPerTx, + }, + }; + + console.log(`\n=== Summary ===`); + console.log(JSON.stringify(report, null, 2)); +} + +main().catch((err: unknown) => { + console.error("\nFatal error:", err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/batch-transfer/typescript/tsconfig.json b/examples/batch-transfer/typescript/tsconfig.json new file mode 100644 index 0000000..0d0500a --- /dev/null +++ b/examples/batch-transfer/typescript/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node16", + "strict": true, + "skipLibCheck": true + } +}