Skip to content
Merged
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
68 changes: 68 additions & 0 deletions bindings/bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -2326,6 +2326,74 @@ func GetUserPositions(client *tnclient.Client) (string, error) {
return string(jsonBytes), nil
}

// GetPositionsByWallet returns a wallet's portfolio (holdings + open orders) by address.
// Unlike GetUserPositions (which reads @caller), this reads the wallet passed in.
func GetPositionsByWallet(client *tnclient.Client, walletAddress string) (string, error) {
ctx := context.Background()

orderBook, err := client.LoadOrderBook()
if err != nil {
return "", errors.Wrap(err, "failed to load order book")
}

results, err := orderBook.GetPositionsByWallet(ctx, types.GetPositionsByWalletInput{
WalletHex: walletAddress,
})
if err != nil {
return "", errors.Wrap(err, "failed to get positions by wallet")
}

positions := make([]map[string]any, len(results))
for i, pos := range results {
positions[i] = map[string]any{
"query_id": pos.QueryID,
"outcome": pos.Outcome,
"price": pos.Price,
"amount": pos.Amount,
"position_type": pos.PositionType,
}
}

jsonBytes, err := json.Marshal(positions)
if err != nil {
return "", errors.Wrap(err, "failed to marshal positions")
}

return string(jsonBytes), nil
}

// GetCollateralByWallet returns a wallet's total locked collateral by address on a bridge.
// Unlike GetUserCollateral (which reads @caller), this reads the wallet passed in.
func GetCollateralByWallet(client *tnclient.Client, walletAddress string, bridge string) (string, error) {
ctx := context.Background()

orderBook, err := client.LoadOrderBook()
if err != nil {
return "", errors.Wrap(err, "failed to load order book")
}

result, err := orderBook.GetCollateralByWallet(ctx, types.GetCollateralByWalletInput{
WalletHex: walletAddress,
Bridge: bridge,
})
if err != nil {
return "", errors.Wrap(err, "failed to get collateral by wallet")
}

collateral := map[string]any{
"total_locked": result.TotalLocked,
"buy_orders_locked": result.BuyOrdersLocked,
"shares_value": result.SharesValue,
}

jsonBytes, err := json.Marshal(collateral)
if err != nil {
return "", errors.Wrap(err, "failed to marshal collateral")
}

return string(jsonBytes), nil
}

// GetMarketDepth returns aggregated volume per price level
func GetMarketDepth(client *tnclient.Client, queryID int, outcome bool) (string, error) {
ctx := context.Background()
Expand Down
35 changes: 35 additions & 0 deletions examples/maa_lifecycle_example/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# MAA lifecycle smoke test — configuration.
#
# Copy this file to `.env` (same directory) and fill in the two private keys:
#
# cp .env.example .env
#
# `.env` is gitignored. Real environment variables take precedence over `.env`,
# so you can still override any value with a shell export.

# --- required: two DISTINCT keys -------------------------------------------------
# Both accept the key with OR without a 0x prefix.
# AGENT = the restricted agent (signs maa_create_rule, runs allow-listed actions AS the MAA).
# OWNER = the unrestricted owner/funder (joins, funds, withdraws; must hold bridged TRUF).
AGENT_PRIVATE_KEY=
OWNER_PRIVATE_KEY=

# --- network -------------------------------------------------------------------
# The testnet RPC/gateway endpoint.
PROVIDER_URL=https://gateway.testnet.truf.network

# --- token / funding -----------------------------------------------------------
# Bridge namespace used to FUND the MAA and pay write fees (the universal fee bridge on dev/testnet).
MAA_BRIDGE=hoodi_tt
# Amount (wei, NUMERIC(78,0)) to fund the MAA with. Must cover the action fees
# (create_streams may cost 100 TRUF where the fee is active).
MAA_FUND_AMOUNT=250000000000000000000
# Owner-withdraw commission paid to the agent, in basis points (250 = 2.5%).
MAA_FEE_BPS=250

# --- portfolio read (migration 051) --------------------------------------------
# Order-book collateral bridge used by get_collateral_by_wallet. This is the bridge
# the order-book MARKETS settle in (hoodi_tt2 / sepolia_bridge / ethereum_bridge on
# dev/testnet) — NOT the hoodi_tt funding/fee bridge above. get_positions_by_wallet
# needs no bridge.
MAA_COLLATERAL_BRIDGE=hoodi_tt2
29 changes: 18 additions & 11 deletions examples/maa_lifecycle_example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ make that safe, mirroring the node's canonical oracle `tests/streams/maa/data_ag
5. **Local derivation matches the chain** — `rule_id` and the MAA address are derived offline
(`compute_rules_hash` / `derive_rule_id` / `derive_maa_address_hex`) and asserted equal to what the
node returns.
6. **Portfolio reads by address** — `get_positions_by_wallet` / `get_collateral_by_wallet`
(migration 051) read the agent wallet's order-book inventory *by address* — the signer is not the
wallet being read — so an owner or a delegated market-maker bot can monitor an agent wallet it
does not sign for. This MAA does data provision (not order-book trading), so the reads return
empty/zero; a clean return is the proof that 051 is live.

## Two identities (required)

Expand All @@ -33,32 +38,34 @@ The agent and the owner are **different keys**:

## Run

Configuration is read from a `.env` file next to the script (real environment variables still
take precedence, so you can override any value with a shell `export`). `.env` is gitignored.

```bash
# from the repo root
uv venv .venv && source .venv/bin/activate && uv pip install -e ".[dev]"

cd examples/maa_lifecycle_example

export PROVIDER_URL="https://gateway.testnet.truf.network" # the testnet RPC/gateway
export AGENT_PRIVATE_KEY=0x... # restricted agent
export OWNER_PRIVATE_KEY=0x... # unrestricted owner (must hold bridged TRUF)
export MAA_BRIDGE="hoodi_tt" # token bridge namespace on this network
export MAA_FUND_AMOUNT="250000000000000000000" # wei to fund the MAA with
# optional: export MAA_FEE_BPS=250 # owner-withdraw commission to the agent (2.5%)
cp .env.example .env # then edit .env: fill in AGENT_PRIVATE_KEY and OWNER_PRIVATE_KEY

python main.py
```

### Environment variables
Each run uses a fresh salt by default, so it registers a new rule/MAA and can be re-run without
colliding with a previous run. Set `MAA_SALT` (64 hex chars) to pin a reproducible rule_id.

### Environment variables (see `.env.example`)

| Variable | Default | Notes |
|----------|---------|-------|
| `PROVIDER_URL` | `https://gateway.testnet.truf.network` | The testnet RPC/gateway. **Confirm the real URL for your network.** |
| `AGENT_PRIVATE_KEY` | — (required) | Restricted agent key. |
| `OWNER_PRIVATE_KEY` | — (required) | Unrestricted owner key; must hold ≥ `MAA_FUND_AMOUNT` + fees of bridged token. |
| `MAA_BRIDGE` | `hoodi_tt` | Token bridge namespace. dev = `hoodi_tt`/`hoodi_tt2`, mainnet = `eth_truf`/`eth_usdc`. **Confirm which exists on testnet.** |
| `AGENT_PRIVATE_KEY` | — (required) | Restricted agent key (with or without `0x`). |
| `OWNER_PRIVATE_KEY` | — (required) | Unrestricted owner key (with or without `0x`); must hold ≥ `MAA_FUND_AMOUNT` + fees of bridged token. |
| `MAA_BRIDGE` | `hoodi_tt` | Funding/fee bridge namespace. dev = `hoodi_tt`/`hoodi_tt2`, mainnet = `eth_truf`/`eth_usdc`. |
| `MAA_FUND_AMOUNT` | `250000000000000000000` (250 TRUF) | Must cover the action fees (`create_streams` may cost 100 TRUF where the fee is active). |
| `MAA_FEE_BPS` | `250` (2.5%) | Owner-withdraw commission paid to the agent. |
| `MAA_COLLATERAL_BRIDGE` | `hoodi_tt2` | Order-book bridge for `get_collateral_by_wallet` (migration 051) — the bridge the markets settle in, **not** the `hoodi_tt` fee bridge. |
| `MAA_SALT` | _(fresh per run)_ | Optional 64-hex salt to pin a reproducible rule_id across runs. |

## Open items to confirm before the first run

Expand Down
66 changes: 54 additions & 12 deletions examples/maa_lifecycle_example/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,13 @@

This mirrors the node's canonical oracle, tests/streams/maa/data_agent_test.go.

Two keys are required (the agent and the owner are DIFFERENT identities):

export PROVIDER_URL="https://gateway.testnet.truf.network" # the testnet RPC/gateway
export AGENT_PRIVATE_KEY=0x... # restricted: signs maa_create_rule + runs actions as the MAA
export OWNER_PRIVATE_KEY=0x... # unrestricted: joins, funds, withdraws (must hold bridged TRUF)
export MAA_BRIDGE="hoodi_tt" # the token bridge namespace on this network (confirm per env)
export MAA_FUND_AMOUNT="250000000000000000000" # wei to fund the MAA with (NUMERIC(78,0))
Config comes from a .env file next to this script (real environment variables still take
precedence). Two DISTINCT keys are required — the agent and the owner are different identities:

cp .env.example .env # then fill in AGENT_PRIVATE_KEY and OWNER_PRIVATE_KEY
python main.py

See README.md for what success looks like and the open items to confirm before the first run.
See .env.example for every setting, and README.md for what success looks like.
"""

import os
Expand All @@ -40,22 +36,53 @@
derive_maa_address_hex,
)

# --- configuration (all overridable via environment) -----------------------------------------------

# --- load .env (zero-dependency; real environment variables take precedence) ------------------------
def _load_dotenv(path: str) -> None:
"""Populate os.environ from a KEY=VALUE .env file, without overriding existing vars."""
if not os.path.exists(path):
return
with open(path) as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'"))


_load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))

# --- configuration (all overridable via environment / .env; see .env.example) ----------------------
PROVIDER_URL = os.getenv("PROVIDER_URL", "https://gateway.testnet.truf.network")
AGENT_PRIVATE_KEY = os.getenv("AGENT_PRIVATE_KEY") # restricted agent
OWNER_PRIVATE_KEY = os.getenv("OWNER_PRIVATE_KEY") # unrestricted owner / funder
BRIDGE = os.getenv("MAA_BRIDGE", "hoodi_tt") # token bridge namespace (e.g. hoodi_tt / eth_truf)
BRIDGE = os.getenv("MAA_BRIDGE", "hoodi_tt") # funding/fee bridge namespace (e.g. hoodi_tt / eth_truf)
FUND_AMOUNT = os.getenv("MAA_FUND_AMOUNT", "250000000000000000000") # wei (NUMERIC(78,0))
FEE_BPS = int(os.getenv("MAA_FEE_BPS", "250")) # 2.5% owner-withdraw commission to the agent
# Order-book collateral bridge for get_collateral_by_wallet (migration 051). This is the bridge the
# order-book MARKETS settle in (hoodi_tt2 / sepolia_bridge / ethereum_bridge on dev/testnet), NOT the
# hoodi_tt funding/fee bridge above. get_positions_by_wallet needs no bridge.
COLLATERAL_BRIDGE = os.getenv("MAA_COLLATERAL_BRIDGE", "hoodi_tt2")

# On-chain decimal types the inner actions declare. A NUMERIC argument MUST be wrapped in MAANumericArg
# with these EXACT precision/scale, because JSON has no decimal type and the node does not coerce text
# to NUMERIC (see TNClient.execute_agent_action / MAANumericArg).
TOKEN_PRECISION, TOKEN_SCALE = 78, 0 # bridge amounts: NUMERIC(78,0)
VALUE_PRECISION, VALUE_SCALE = 36, 18 # primitive record values: NUMERIC(36,18)

# A fixed salt makes the rule_id (and therefore the MAA) deterministic for this agent+owner pair.
SALT = b"\x4d\x41\x41" + b"\x00" * 29 # b"MAA" + padding, 32 bytes
# A fresh salt per run keeps each smoke test independent: it yields a new rule_id (and therefore a
# new MAA), so re-running never collides with an already-registered rule. The local-derivation
# cross-checks below use this exact salt, so they stay valid. Set MAA_SALT (64 hex chars, optional
# 0x) to pin a reproducible rule_id across runs.
_salt_env = os.getenv("MAA_SALT")
if _salt_env:
SALT = bytes.fromhex(_salt_env[2:] if _salt_env.startswith("0x") else _salt_env)
else:
# Nanosecond precision so two runs in the same second still get distinct salts; the ns
# value (~1.8e18) fits in 8 unsigned bytes (max ~1.8e19).
SALT = b"MAA" + time.time_ns().to_bytes(8, "big") + b"\x00" * 21 # 3 + 8 + 21 = 32 bytes
assert len(SALT) == 32, f"salt must be 32 bytes, got {len(SALT)}"

NAMESPACES = ["main", "main"]
ACTIONS = ["create_streams", "insert_records"] # the agent's allow-list (mirrors data_agent_test.go)
Expand Down Expand Up @@ -212,12 +239,27 @@ def main() -> int:
print(f" - {ev['event_type']:<12} role={ev['actor_role']:<13} actor={ev['actor_addr']} "
f"action={ev.get('inner_action') or '-'} amount={ev.get('amount') or '-'}")

# (h) READ the agent wallet's ORDER-BOOK portfolio BY ADDRESS (migration 051).
# get_positions_by_wallet / get_collateral_by_wallet read the wallet you pass in (NOT
# @caller), so an owner — or a delegated market-maker bot — can read an agent wallet's live
# inventory without holding its key. The signer here (agent) differs from the wallet read
# (the MAA), which is the whole point. This MAA's allow-list is create_streams/insert_records
# (data provision), so it holds NO order-book positions — the reads return empty/zero. A clean
# return (instead of "unknown action") is the proof that migration 051 is live on this network.
banner("(h) read the agent wallet's order-book portfolio by address")
positions = agent.get_positions_by_wallet(maa)
collateral = agent.get_collateral_by_wallet(maa, COLLATERAL_BRIDGE)
print(f"get_positions_by_wallet({maa}) -> {len(positions)} positions: {positions}")
print(f"get_collateral_by_wallet({maa}, {COLLATERAL_BRIDGE}) -> {collateral}")
print("✅ address-parameterized portfolio reads are live (migration 051)")

banner("✅ MAA lifecycle smoke test PASSED")
print("Proven on-chain via the Python SDK:")
print(" • @caller rewritten to the MAA (the stream is owned by the wallet, not the agent key)")
print(" • fees debit the MAA's own escrow")
print(" • the restricted agent cannot move funds out")
print(" • the owner withdraws with the agreed commission")
print(" • an owner can read the wallet's order-book positions/collateral by address (051)")
return 0


Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9
github.com/pkg/errors v0.9.1
github.com/trufnetwork/kwil-db/core v0.4.3-0.20260615121733-0d71bd259558
github.com/trufnetwork/sdk-go v0.7.3-0.20260622120135-f266b733466c
github.com/trufnetwork/sdk-go v0.7.3-0.20260623101902-a88c54ac6abb
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ github.com/trufnetwork/kwil-db v0.10.3-0.20260615121733-0d71bd259558 h1:I49YGUiU
github.com/trufnetwork/kwil-db v0.10.3-0.20260615121733-0d71bd259558/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo=
github.com/trufnetwork/kwil-db/core v0.4.3-0.20260615121733-0d71bd259558 h1:m7a9HITFMXJF22QznIKoAdeiH8eZxWPU8IRzgcyNMo8=
github.com/trufnetwork/kwil-db/core v0.4.3-0.20260615121733-0d71bd259558/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ=
github.com/trufnetwork/sdk-go v0.7.3-0.20260622120135-f266b733466c h1:CfoiZyqRH8VCLSQPrm77ntxgQgOfi/b99nGIukQR2eI=
github.com/trufnetwork/sdk-go v0.7.3-0.20260622120135-f266b733466c/go.mod h1:CivlBpc5RcYwyo8EysoAZWXXfmTtigfkR5VctivwSHc=
github.com/trufnetwork/sdk-go v0.7.3-0.20260623101902-a88c54ac6abb h1:NyKYWkkmeShfSR+2FjAg78s+k0LY5A95Yayh4H+yPjo=
github.com/trufnetwork/sdk-go v0.7.3-0.20260623101902-a88c54ac6abb/go.mod h1:CivlBpc5RcYwyo8EysoAZWXXfmTtigfkR5VctivwSHc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
Expand Down
32 changes: 32 additions & 0 deletions src/trufnetwork_sdk_py/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2804,6 +2804,38 @@ def get_user_collateral(self) -> UserCollateral:

return cast(UserCollateral, json.loads(json_str))

def get_positions_by_wallet(self, wallet_address: str) -> list[UserPosition]:
"""Get a wallet's portfolio (holdings + open orders) by address.

Unlike get_user_positions (which reads the signer), this reads the wallet you
pass in — so an owner can read an agent wallet's (MAA) positions without holding
its key. ``wallet_address`` is a 0x-prefixed (or bare) 20-byte hex address.
"""
json_str = truf_sdk.GetPositionsByWallet(self.client, wallet_address)

if not json_str:
return []

return cast(list[UserPosition], json.loads(json_str))

def get_collateral_by_wallet(self, wallet_address: str, bridge: str) -> UserCollateral:
"""Get a wallet's total locked collateral by address, on a given bridge.

Unlike get_user_collateral (which reads the signer), this reads the wallet you
pass in. ``bridge`` is required (per-bridge token decimals), e.g. "hoodi_tt" /
"eth_truf". ``wallet_address`` is a 0x-prefixed (or bare) 20-byte hex address.
"""
json_str = truf_sdk.GetCollateralByWallet(self.client, wallet_address, bridge)

if not json_str:
return {
"total_locked": "0",
"buy_orders_locked": "0",
"shares_value": "0",
}

return cast(UserCollateral, json.loads(json_str))

# ═══════════════════════════════════════════════════════════════
# ORDER BOOK - SETTLEMENT & REWARDS
# ═══════════════════════════════════════════════════════════════
Expand Down
Loading
Loading