pythsol 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. pythsol/__init__.py +317 -0
  2. pythsol/cli/__init__.py +7 -0
  3. pythsol/cli/cache.py +78 -0
  4. pythsol/cli/cluster.py +45 -0
  5. pythsol/cli/commands/build.py +214 -0
  6. pythsol/cli/commands/check.py +54 -0
  7. pythsol/cli/commands/client_cmd.py +97 -0
  8. pythsol/cli/commands/deploy.py +103 -0
  9. pythsol/cli/commands/docs_cmd.py +62 -0
  10. pythsol/cli/commands/doctor.py +211 -0
  11. pythsol/cli/commands/emit_c.py +69 -0
  12. pythsol/cli/commands/faucet.py +66 -0
  13. pythsol/cli/commands/idl_cmd.py +69 -0
  14. pythsol/cli/commands/idl_publish.py +80 -0
  15. pythsol/cli/commands/init.py +65 -0
  16. pythsol/cli/commands/port_cmd.py +42 -0
  17. pythsol/cli/commands/profile.py +57 -0
  18. pythsol/cli/commands/serve.py +52 -0
  19. pythsol/cli/commands/test_cmd.py +217 -0
  20. pythsol/cli/commands/verify.py +96 -0
  21. pythsol/cli/commands/wallet.py +128 -0
  22. pythsol/cli/config.py +84 -0
  23. pythsol/cli/main.py +72 -0
  24. pythsol/cli/rpc.py +56 -0
  25. pythsol/cli/template_programs/__init__.py +1 -0
  26. pythsol/cli/template_programs/counter.py +52 -0
  27. pythsol/cli/template_programs/escrow.py +75 -0
  28. pythsol/cli/template_programs/ledger.py +68 -0
  29. pythsol/cli/template_programs/multisig.py +91 -0
  30. pythsol/cli/template_programs/poll.py +70 -0
  31. pythsol/cli/template_programs/vault.py +67 -0
  32. pythsol/cli/templates.py +192 -0
  33. pythsol/client/__init__.py +5 -0
  34. pythsol/client/from_idl.py +356 -0
  35. pythsol/client/gen.py +496 -0
  36. pythsol/client/port.py +355 -0
  37. pythsol/compiler/__init__.py +39 -0
  38. pythsol/compiler/diagnostics.py +130 -0
  39. pythsol/compiler/emit.py +3550 -0
  40. pythsol/compiler/front.py +84 -0
  41. pythsol/compiler/idl.py +465 -0
  42. pythsol/compiler/model.py +1329 -0
  43. pythsol/compiler/modules.py +320 -0
  44. pythsol/compiler/parse.py +59 -0
  45. pythsol/compiler/pipeline.py +152 -0
  46. pythsol/compiler/resolve.py +518 -0
  47. pythsol/compiler/subset.py +1061 -0
  48. pythsol/compiler/toolchain.py +438 -0
  49. pythsol/compiler/typecheck.py +2447 -0
  50. pythsol/playground/__init__.py +7 -0
  51. pythsol/playground/app.css +182 -0
  52. pythsol/playground/app.js +260 -0
  53. pythsol/playground/apple-touch-icon.png +0 -0
  54. pythsol/playground/docs.html +61 -0
  55. pythsol/playground/favicon-32.png +0 -0
  56. pythsol/playground/favicon.ico +0 -0
  57. pythsol/playground/favicon.svg +24 -0
  58. pythsol/playground/hero-bg.webp +0 -0
  59. pythsol/playground/index.html +66 -0
  60. pythsol/playground/landing.html +810 -0
  61. pythsol/playground/playground.html +77 -0
  62. pythsol/playground/robots.txt +4 -0
  63. pythsol/playground/server.py +336 -0
  64. pythsol/playground/site-bg.svg +146 -0
  65. pythsol/playground/site.css +1105 -0
  66. pythsol/py.typed +0 -0
  67. pythsol/refrt/__init__.py +200 -0
  68. pythsol/runtime/__init__.py +17 -0
  69. pythsol/runtime/pythsol_rt.h +1216 -0
  70. pythsol/testing/__init__.py +12 -0
  71. pythsol/testing/plugin.py +437 -0
  72. pythsol-0.1.0.dist-info/METADATA +178 -0
  73. pythsol-0.1.0.dist-info/RECORD +76 -0
  74. pythsol-0.1.0.dist-info/WHEEL +4 -0
  75. pythsol-0.1.0.dist-info/entry_points.txt +6 -0
  76. pythsol-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,128 @@
1
+ """`pythsol wallet` — new keypair, address, and balance.
2
+
3
+ The keypair file is the Solana JSON array (64 bytes). It is written mode
4
+ ``0600``. The secret is not printed unless ``--show-secret`` is set.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+ from pythsol.cli.cluster import resolve_url
17
+ from pythsol.cli.rpc import RpcError, post_json
18
+ from pythsol.compiler.toolchain import default_solana_keypair
19
+
20
+
21
+ def restrict_private_file(path: Path) -> None:
22
+ """Owner read/write only. On Windows, chmod is the best the CRT offers."""
23
+ os.chmod(path, 0o600)
24
+
25
+
26
+ def _keypair_type() -> Any:
27
+ try:
28
+ from solders.keypair import Keypair
29
+ except ImportError:
30
+ click.echo("error: solders is required. pip install 'pythsol[test]'", err=True)
31
+ sys.exit(1)
32
+ return Keypair
33
+
34
+
35
+ def _load(path: Path) -> Any:
36
+ return _keypair_type().from_json(path.read_text(encoding="utf-8"))
37
+
38
+
39
+ def _lamports(body: dict[str, object]) -> int:
40
+ result = body.get("result")
41
+ if isinstance(result, dict) and "value" in result:
42
+ return int(result["value"])
43
+ if isinstance(result, int):
44
+ return result
45
+ raise RpcError("balance response had no value")
46
+
47
+
48
+ @click.group(name="wallet")
49
+ def wallet() -> None:
50
+ """Create a keypair, print its address, or read a balance."""
51
+
52
+
53
+ @wallet.command("new")
54
+ @click.option(
55
+ "--outfile",
56
+ required=True,
57
+ type=click.Path(dir_okay=False, path_type=Path),
58
+ help="Where to write the keypair JSON.",
59
+ )
60
+ @click.option("--force", is_flag=True, help="Overwrite an existing file.")
61
+ @click.option("--show-secret", is_flag=True, help="Print the keypair JSON. Off by default.")
62
+ def wallet_new(outfile: Path, force: bool, show_secret: bool) -> None:
63
+ """Write a new keypair file."""
64
+ if outfile.exists() and not force:
65
+ click.echo(f"error: {outfile} already exists (pass --force to overwrite)", err=True)
66
+ sys.exit(1)
67
+ outfile.parent.mkdir(parents=True, exist_ok=True)
68
+ keypair = _keypair_type()()
69
+ text = keypair.to_json()
70
+ if not text.endswith("\n"):
71
+ text += "\n"
72
+ outfile.write_text(text, encoding="utf-8", newline="\n")
73
+ restrict_private_file(outfile)
74
+ click.echo(f"wrote {outfile}")
75
+ click.echo(str(keypair.pubkey()))
76
+ if show_secret:
77
+ click.echo(text, nl=False)
78
+ sys.exit(0)
79
+
80
+
81
+ @wallet.command("address")
82
+ @click.option(
83
+ "--keypair",
84
+ "keypair_path",
85
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
86
+ default=None,
87
+ )
88
+ @click.option("--show-secret", is_flag=True, help="Print the keypair JSON. Off by default.")
89
+ def wallet_address(keypair_path: Path | None, show_secret: bool) -> None:
90
+ """Print the public key. The secret stays in the file."""
91
+ path = keypair_path or default_solana_keypair()
92
+ if not path.is_file():
93
+ click.echo(f"error: keypair not found at {path}", err=True)
94
+ sys.exit(1)
95
+ keypair = _load(path)
96
+ click.echo(str(keypair.pubkey()))
97
+ if show_secret:
98
+ click.echo(keypair.to_json())
99
+ sys.exit(0)
100
+
101
+
102
+ @wallet.command("balance")
103
+ @click.option("--url", default=None, help="Cluster name or RPC URL. Default: devnet.")
104
+ @click.option("--pubkey", default=None, help="Base58 address. Default: the local keypair.")
105
+ @click.option(
106
+ "--keypair",
107
+ "keypair_path",
108
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
109
+ default=None,
110
+ )
111
+ def wallet_balance(url: str | None, pubkey: str | None, keypair_path: Path | None) -> None:
112
+ """Print the SOL balance of a public key."""
113
+ if pubkey is None:
114
+ path = keypair_path or default_solana_keypair()
115
+ if not path.is_file():
116
+ click.echo(f"error: keypair not found at {path}", err=True)
117
+ sys.exit(1)
118
+ pubkey = str(_load(path).pubkey())
119
+ cluster = resolve_url(url)
120
+ try:
121
+ body = post_json(cluster, "getBalance", [pubkey])
122
+ lamports = _lamports(body)
123
+ except RpcError as exc:
124
+ click.echo(f"error: {exc}", err=True)
125
+ sys.exit(1)
126
+ sol = lamports / 1_000_000_000
127
+ click.echo(f"{sol:.4f} SOL ({lamports} lamports)")
128
+ sys.exit(0)
pythsol/cli/config.py ADDED
@@ -0,0 +1,84 @@
1
+ """Read ``pysol.toml`` project files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ __all__ = ["ConfigError", "ProgramEntry", "ProjectConfig", "find_config", "load_config"]
11
+
12
+
13
+ class ConfigError(Exception):
14
+ """``pysol.toml`` is missing a required field or has the wrong shape."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProgramEntry:
19
+ """One ``[[programs]]`` row."""
20
+
21
+ name: str
22
+ entry: Path
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ProjectConfig:
27
+ """The fields `pythsol test` and `pythsol build` read from a project."""
28
+
29
+ path: Path
30
+ name: str
31
+ programs: tuple[ProgramEntry, ...]
32
+ out_dir: Path
33
+ cluster: str
34
+
35
+
36
+ def find_config(start: Path) -> Path | None:
37
+ """Walk from ``start`` toward the filesystem root looking for ``pysol.toml``."""
38
+ current = start.resolve()
39
+ if current.is_file():
40
+ current = current.parent
41
+ for directory in [current, *current.parents]:
42
+ candidate = directory / "pysol.toml"
43
+ if candidate.is_file():
44
+ return candidate
45
+ return None
46
+
47
+
48
+ def load_config(path: Path) -> ProjectConfig:
49
+ """Parse ``path``. Raises :class:`ConfigError` on a bad shape."""
50
+ raw: dict[str, Any] = tomllib.loads(path.read_text(encoding="utf-8"))
51
+ project = raw.get("project")
52
+ build = raw.get("build")
53
+ cluster = raw.get("cluster")
54
+ programs_raw = raw.get("programs")
55
+ if not isinstance(programs_raw, list) or not programs_raw:
56
+ raise ConfigError("pysol.toml needs at least one [[programs]] entry")
57
+ root = path.parent
58
+ programs: list[ProgramEntry] = []
59
+ for item in programs_raw:
60
+ if not isinstance(item, dict):
61
+ raise ConfigError("[[programs]] entries must be tables")
62
+ name = item.get("name")
63
+ entry = item.get("entry")
64
+ if not isinstance(name, str) or not name:
65
+ raise ConfigError("a program needs a name")
66
+ if not isinstance(entry, str) or not entry:
67
+ raise ConfigError(f"program `{name}` needs an entry path")
68
+ programs.append(ProgramEntry(name, (root / entry).resolve()))
69
+ out_raw = "target/pythsol"
70
+ if isinstance(build, dict) and isinstance(build.get("out_dir"), str):
71
+ out_raw = build["out_dir"]
72
+ default_cluster = "devnet"
73
+ if isinstance(cluster, dict) and isinstance(cluster.get("default"), str):
74
+ default_cluster = cluster["default"]
75
+ project_name = root.name
76
+ if isinstance(project, dict) and isinstance(project.get("name"), str):
77
+ project_name = project["name"]
78
+ return ProjectConfig(
79
+ path=path,
80
+ name=project_name,
81
+ programs=tuple(programs),
82
+ out_dir=(root / out_raw).resolve(),
83
+ cluster=default_cluster,
84
+ )
pythsol/cli/main.py ADDED
@@ -0,0 +1,72 @@
1
+ """CLI entry point (`pythsol`, aliased as the draft `pysol`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import click
9
+
10
+ from pythsol import __version__
11
+ from pythsol.cli.commands.build import build
12
+ from pythsol.cli.commands.check import check
13
+ from pythsol.cli.commands.client_cmd import client_cmd, cpi_cmd
14
+ from pythsol.cli.commands.deploy import deploy
15
+ from pythsol.cli.commands.docs_cmd import docs
16
+ from pythsol.cli.commands.doctor import doctor
17
+ from pythsol.cli.commands.emit_c import emit_c
18
+ from pythsol.cli.commands.faucet import faucet
19
+ from pythsol.cli.commands.idl_cmd import idl
20
+ from pythsol.cli.commands.idl_publish import idl_publish
21
+ from pythsol.cli.commands.init import init
22
+ from pythsol.cli.commands.port_cmd import port_cmd
23
+ from pythsol.cli.commands.profile import profile
24
+ from pythsol.cli.commands.serve import serve
25
+ from pythsol.cli.commands.test_cmd import test_cmd
26
+ from pythsol.cli.commands.verify import verify
27
+ from pythsol.cli.commands.wallet import wallet
28
+
29
+
30
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
31
+ @click.version_option(__version__, "--version", message="%(prog)s %(version)s")
32
+ @click.option("--quiet", is_flag=True, help="Silence non-essential output.")
33
+ @click.option("--verbose", is_flag=True, help="Show external commands that are run.")
34
+ def cli(quiet: bool, verbose: bool) -> None:
35
+ """PythSol — Solana programs in typed Python."""
36
+ # Per-command implementations use these flags; Phase 1 only needs doctor.
37
+ _ = quiet, verbose
38
+
39
+
40
+ def main(prog_name: str | None = None) -> None:
41
+ """Module entry point: "pythsol" or "pysol" depending on packaging aliases."""
42
+ prog = prog_name or os.path.basename(sys.argv[0]) or "pythsol"
43
+ cli(prog_name=prog) # `Group.__call__` forwards to `Group.main(prog_name=prog)`.
44
+
45
+
46
+ # Re-attach commands after the group definition so auto-help works uniformly.
47
+ cli.add_command(doctor)
48
+ cli.add_command(check)
49
+ cli.add_command(emit_c)
50
+ cli.add_command(build)
51
+ cli.add_command(test_cmd)
52
+ cli.add_command(init)
53
+ cli.add_command(idl)
54
+ cli.add_command(idl_publish)
55
+ cli.add_command(client_cmd)
56
+ cli.add_command(cpi_cmd)
57
+ cli.add_command(deploy)
58
+ cli.add_command(faucet)
59
+ cli.add_command(wallet)
60
+ cli.add_command(port_cmd)
61
+ cli.add_command(verify)
62
+ cli.add_command(profile)
63
+ cli.add_command(docs)
64
+ cli.add_command(serve)
65
+
66
+
67
+ def _entrypoint() -> None: # pragma: no cover — packaging script
68
+ main()
69
+
70
+
71
+ if __name__ == "__main__": # pragma: no cover
72
+ _entrypoint()
pythsol/cli/rpc.py ADDED
@@ -0,0 +1,56 @@
1
+ """JSON-RPC helper for faucet and wallet balance.
2
+
3
+ The compiler never calls this. Commands that talk to a cluster the user named
4
+ do. The body is a JSON-RPC request and the response is parsed as JSON.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.request import Request, urlopen
13
+
14
+ __all__ = ["RpcError", "post_json"]
15
+
16
+
17
+ class RpcError(Exception):
18
+ """The endpoint refused the call or returned an error object."""
19
+
20
+
21
+ def post_json(
22
+ url: str,
23
+ method: str,
24
+ params: list[object],
25
+ *,
26
+ timeout: float = 20.0,
27
+ ) -> dict[str, Any]:
28
+ """POST one JSON-RPC request and return the decoded object."""
29
+ payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(
30
+ "utf-8"
31
+ )
32
+ request = Request(
33
+ url,
34
+ data=payload,
35
+ headers={"content-type": "application/json"},
36
+ method="POST",
37
+ )
38
+ try:
39
+ with urlopen(request, timeout=timeout) as response:
40
+ raw = response.read()
41
+ except HTTPError as exc:
42
+ detail = exc.read().decode("utf-8", errors="replace")
43
+ raise RpcError(detail or f"HTTP {exc.code}") from exc
44
+ except URLError as exc:
45
+ raise RpcError(str(exc.reason)) from exc
46
+ try:
47
+ body = json.loads(raw.decode("utf-8"))
48
+ except json.JSONDecodeError as exc:
49
+ raise RpcError("RPC response was not JSON") from exc
50
+ if not isinstance(body, dict):
51
+ raise RpcError("RPC response was not an object")
52
+ if "error" in body and body["error"] is not None:
53
+ error = body["error"]
54
+ message = error.get("message") if isinstance(error, dict) else str(error)
55
+ raise RpcError(str(message))
56
+ return body
@@ -0,0 +1 @@
1
+ """Embedded `pythsol init` program sources. Read as text; do not import them."""
@@ -0,0 +1,52 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """A minimal PythSol program: an owned counter.
3
+
4
+ `pythsol check programs/counter.py` must pass `mypy --strict` *and* the
5
+ PythSol front-end. `pythsol build` (Phase 3) compiles it to native SBF.
6
+ """
7
+
8
+ from pythsol import (
9
+ Init,
10
+ Mut,
11
+ ProgramError,
12
+ Pubkey,
13
+ Signer,
14
+ account,
15
+ error,
16
+ instruction,
17
+ require,
18
+ u8,
19
+ u64,
20
+ )
21
+
22
+
23
+ @account
24
+ class Counter:
25
+ """The on-chain counter account layout."""
26
+
27
+ authority: Pubkey
28
+ count: u64
29
+ bump: u8
30
+
31
+
32
+ @error
33
+ class CounterError(ProgramError):
34
+ """Errors returned by this program (u32 codes)."""
35
+
36
+ Unauthorized = 100
37
+ Overflow = 101
38
+
39
+
40
+ @instruction
41
+ def initialize(authority: Signer, counter: Init[Counter], bump: u8) -> None:
42
+ """Create and initialize the counter authority fields."""
43
+ counter.authority = authority.key
44
+ counter.count = 0
45
+ counter.bump = bump
46
+
47
+
48
+ @instruction
49
+ def increment(authority: Signer, counter: Mut[Counter], by: u64) -> None:
50
+ """Add `by` to the count; only the stored authority may call this."""
51
+ require(counter.authority == authority.key, CounterError.Unauthorized)
52
+ counter.count += by
@@ -0,0 +1,75 @@
1
+ """Escrow: one PDA deal funded in lamports.
2
+
3
+ The seed is the literal ``escrow``, so a program has one open deal. ``make``
4
+ creates it and transfers ``amount`` in. ``take`` pays the maker, then closes
5
+ the account to the taker. ``cancel`` closes it back to the maker. A
6
+ PDA-signed transfer is not available; closing the account is what returns
7
+ the escrowed lamports.
8
+ """
9
+
10
+ from pythsol import (
11
+ CloseTo,
12
+ Init,
13
+ Mut,
14
+ ProgramError,
15
+ Pubkey,
16
+ Seeds,
17
+ Signer,
18
+ account,
19
+ error,
20
+ instruction,
21
+ require,
22
+ system_transfer,
23
+ u8,
24
+ u64,
25
+ )
26
+
27
+
28
+ @account
29
+ class Escrow:
30
+ maker: Pubkey
31
+ taker: Pubkey
32
+ amount: u64
33
+ bump: u8
34
+
35
+
36
+ @error
37
+ class EscrowError(ProgramError):
38
+ """Errors returned by this program (6000 + index)."""
39
+
40
+ Unauthorized = 100
41
+
42
+
43
+ @instruction
44
+ def make(
45
+ maker: Signer,
46
+ escrow: Seeds[Init[Escrow], "escrow"], # type: ignore[name-defined] # noqa: F821
47
+ taker: Pubkey,
48
+ bump: u8,
49
+ amount: u64,
50
+ ) -> None:
51
+ escrow.maker = maker.key
52
+ escrow.taker = taker
53
+ escrow.amount = amount
54
+ escrow.bump = bump
55
+ system_transfer(maker, escrow, amount)
56
+
57
+
58
+ @instruction
59
+ def take(
60
+ taker: Signer,
61
+ maker: Signer,
62
+ escrow: CloseTo[Mut[Escrow], taker], # type: ignore[name-defined] # noqa: F821
63
+ price: u64,
64
+ ) -> None:
65
+ require(escrow.taker == taker.key, EscrowError.Unauthorized)
66
+ require(escrow.maker == maker.key, EscrowError.Unauthorized)
67
+ system_transfer(taker, maker, price)
68
+
69
+
70
+ @instruction
71
+ def cancel(
72
+ maker: Signer,
73
+ escrow: CloseTo[Mut[Escrow], maker], # type: ignore[name-defined] # noqa: F821
74
+ ) -> None:
75
+ require(escrow.maker == maker.key, EscrowError.Unauthorized)
@@ -0,0 +1,68 @@
1
+ """Ledger program: fixed slots, deposits, and a checked running total."""
2
+
3
+ from pythsol import (
4
+ FixedArray,
5
+ Init,
6
+ Mut,
7
+ ProgramError,
8
+ Pubkey,
9
+ Signer,
10
+ account,
11
+ error,
12
+ instruction,
13
+ require,
14
+ u8,
15
+ u64,
16
+ )
17
+
18
+
19
+ @account
20
+ class Ledger:
21
+ authority: Pubkey
22
+ total: u64
23
+ slots: FixedArray[u64, 4] # type: ignore[valid-type]
24
+ bump: u8
25
+
26
+
27
+ @error
28
+ class LedgerError(ProgramError):
29
+ """Errors returned by this program (6000 + index)."""
30
+
31
+ Unauthorized = 100
32
+ BadSlot = 101
33
+ Insufficient = 102
34
+
35
+
36
+ @instruction
37
+ def initialize(authority: Signer, ledger: Init[Ledger], bump: u8) -> None:
38
+ ledger.authority = authority.key
39
+ ledger.total = 0
40
+ ledger.bump = bump
41
+
42
+
43
+ @instruction
44
+ def deposit(authority: Signer, ledger: Mut[Ledger], slot: u8, amount: u64) -> None:
45
+ require(ledger.authority == authority.key, LedgerError.Unauthorized)
46
+ require(slot < 4, LedgerError.BadSlot)
47
+ ledger.slots[slot] = ledger.slots[slot] + amount
48
+ ledger.total = ledger.total + amount
49
+
50
+
51
+ @instruction
52
+ def apply(authority: Signer, ledger: Mut[Ledger], amount: u64, mode: u8) -> None:
53
+ require(ledger.authority == authority.key, LedgerError.Unauthorized)
54
+ if mode == 0:
55
+ ledger.total = ledger.total + amount
56
+ else:
57
+ require(ledger.total >= amount, LedgerError.Insufficient)
58
+ ledger.total = ledger.total - amount
59
+
60
+
61
+ @instruction
62
+ def fill(authority: Signer, ledger: Mut[Ledger], value: u64) -> None:
63
+ require(ledger.authority == authority.key, LedgerError.Unauthorized)
64
+ total: u64 = 0
65
+ for i in range(4):
66
+ ledger.slots[i] = value
67
+ total = total + value
68
+ ledger.total = total
@@ -0,0 +1,91 @@
1
+ """A 2-of-3 treasury. Owners approve a payout; the PDA signs the transfer."""
2
+
3
+ from pythsol import (
4
+ FixedArray,
5
+ Init,
6
+ Mut,
7
+ ProgramError,
8
+ Pubkey,
9
+ Seeds,
10
+ Signer,
11
+ account,
12
+ error,
13
+ instruction,
14
+ require,
15
+ system_transfer,
16
+ u8,
17
+ u64,
18
+ )
19
+
20
+
21
+ @account
22
+ class Treasury:
23
+ creator: Pubkey
24
+ owners: FixedArray[Pubkey, 3]
25
+ approvals: u8
26
+ threshold: u8
27
+ paid: u64
28
+ bump: u8
29
+
30
+
31
+ @error
32
+ class TreasuryError(ProgramError):
33
+ """Errors returned by this program (6000 + index)."""
34
+
35
+ Unauthorized = 100
36
+ BadSlot = 101
37
+ AlreadyApproved = 102
38
+ NotApproved = 103
39
+ Threshold = 104
40
+
41
+
42
+ @instruction
43
+ def initialize(
44
+ authority: Signer,
45
+ treasury: Seeds[Init[Treasury], "treasury"], # type: ignore[name-defined] # noqa: F821
46
+ first: Pubkey,
47
+ second: Pubkey,
48
+ third: Pubkey,
49
+ threshold: u8,
50
+ bump: u8,
51
+ ) -> None:
52
+ require(threshold >= 1, TreasuryError.Threshold)
53
+ require(threshold <= 3, TreasuryError.Threshold)
54
+ treasury.creator = authority.key
55
+ treasury.owners[0] = first
56
+ treasury.owners[1] = second
57
+ treasury.owners[2] = third
58
+ treasury.approvals = 0
59
+ treasury.threshold = threshold
60
+ treasury.paid = 0
61
+ treasury.bump = bump
62
+
63
+
64
+ @instruction
65
+ def fund(donor: Signer, treasury: Mut[Treasury], amount: u64) -> None:
66
+ system_transfer(donor, treasury, amount)
67
+
68
+
69
+ @instruction
70
+ def approve(owner: Signer, treasury: Mut[Treasury], slot: u8) -> None:
71
+ require(slot < 3, TreasuryError.BadSlot)
72
+ require(treasury.owners[slot] == owner.key, TreasuryError.Unauthorized)
73
+ mask: u8 = 1 << slot
74
+ require((treasury.approvals & mask) == 0, TreasuryError.AlreadyApproved)
75
+ treasury.approvals = treasury.approvals | mask
76
+
77
+
78
+ @instruction
79
+ def release(owner: Signer, treasury: Mut[Treasury], dest: Signer, slot: u8, amount: u64) -> None:
80
+ require(slot < 3, TreasuryError.BadSlot)
81
+ require(treasury.owners[slot] == owner.key, TreasuryError.Unauthorized)
82
+ mask: u8 = 1 << slot
83
+ require((treasury.approvals & mask) != 0, TreasuryError.NotApproved)
84
+ count: u8 = 0
85
+ for i in range(3):
86
+ if ((treasury.approvals >> i) & 1) == 1:
87
+ count = count + 1
88
+ require(count >= treasury.threshold, TreasuryError.Threshold)
89
+ treasury.approvals = 0
90
+ system_transfer(treasury, dest, amount)
91
+ treasury.paid = treasury.paid + amount
@@ -0,0 +1,70 @@
1
+ """A four-voter poll. Each allowlisted key votes once; the tally lives on chain."""
2
+
3
+ from pythsol import (
4
+ FixedArray,
5
+ Init,
6
+ Mut,
7
+ ProgramError,
8
+ Pubkey,
9
+ Seeds,
10
+ Signer,
11
+ account,
12
+ error,
13
+ instruction,
14
+ require,
15
+ u8,
16
+ u32,
17
+ )
18
+
19
+
20
+ @account
21
+ class Poll:
22
+ authority: Pubkey
23
+ voters: FixedArray[Pubkey, 4]
24
+ voted: u8
25
+ yes: u32
26
+ no: u32
27
+ bump: u8
28
+
29
+
30
+ @error
31
+ class PollError(ProgramError):
32
+ """Errors returned by this program (6000 + index)."""
33
+
34
+ BadSlot = 100
35
+ Unauthorized = 101
36
+ AlreadyVoted = 102
37
+
38
+
39
+ @instruction
40
+ def initialize(
41
+ authority: Signer,
42
+ poll: Seeds[Init[Poll], "poll"], # type: ignore[name-defined] # noqa: F821
43
+ first: Pubkey,
44
+ second: Pubkey,
45
+ third: Pubkey,
46
+ fourth: Pubkey,
47
+ bump: u8,
48
+ ) -> None:
49
+ poll.authority = authority.key
50
+ poll.voters[0] = first
51
+ poll.voters[1] = second
52
+ poll.voters[2] = third
53
+ poll.voters[3] = fourth
54
+ poll.voted = 0
55
+ poll.yes = 0
56
+ poll.no = 0
57
+ poll.bump = bump
58
+
59
+
60
+ @instruction
61
+ def vote(voter: Signer, poll: Mut[Poll], slot: u8, choice: bool) -> None:
62
+ require(slot < 4, PollError.BadSlot)
63
+ require(poll.voters[slot] == voter.key, PollError.Unauthorized)
64
+ mask: u8 = 1 << slot
65
+ require((poll.voted & mask) == 0, PollError.AlreadyVoted)
66
+ poll.voted = poll.voted | mask
67
+ if choice:
68
+ poll.yes = poll.yes + 1
69
+ else:
70
+ poll.no = poll.no + 1