Skip to content

contract: add read-only Soroban contract invocation#5946

Open
marwen-abid wants to merge 6 commits into
feat/contract-layerfrom
feat/EN-51-contract-invoke-read
Open

contract: add read-only Soroban contract invocation#5946
marwen-abid wants to merge 6 commits into
feat/contract-layerfrom
feat/EN-51-contract-invoke-read

Conversation

@marwen-abid

Copy link
Copy Markdown
PR Checklist

PR Structure

  • This PR has reasonably narrow scope (if not, break it down into smaller PRs).
  • This PR avoids mixing refactoring changes with feature changes (split into two PRs
    otherwise).
  • This PR's title starts with name of package that is most changed in the PR, ex.
    services/friendbot, or all or doc if the changes are broad or impact many
    packages.

Thoroughness

  • This PR adds tests for the most critical parts of the new functionality or fixes.
  • I've updated any docs (developer docs, .md
    files, etc... affected by this change). Take a look in the docs folder for a given service,
    like this one.

Release planning

  • I've reviewed the changes in this PR and if I consider them worthwhile for being mentioned on release notes then I have updated the relevant CHANGELOG.md within the component folder structure. For example, if I changed horizon, then I updated (services/horizon/CHANGELOG.md. I add a new line item describing the change and reference to this PR. If I don't update a CHANGELOG, I acknowledge this PR's change may not be mentioned in future release notes.
  • I've decided if this PR requires a new major/minor version according to
    semver, or if it's mainly a patch change. The PR is targeted at the next
    release branch if it's not a patch change.

What

Adds a new contract package providing a higher-level surface for invoking Soroban contracts. This PR is intentionally scoped to the read-only path: it carries a generic contract call from build → simulate → decoded return value, with no signer and no submission.

Adds:

  • contract.Client (New, Invoke) — generic invocation against any contract. Invoke builds an InvokeHostFunction op, simulates it, and returns an AssembledTransaction ready to read. Configured with functional options (WithDefaultSource/WithSource, WithBaseFee, WithResourceFeeMultiplier, WithMemo, WithTimeBounds). Read-only calls need no funded source — they simulate against a synthetic null account.
  • contract.AssembledTransaction (Simulate) — models the Soroban transaction lifecycle as an explicit, ctx-driven type. Simulate folds the simulated SorobanData footprint, auth entries, and (verbatim, un-padded) minResourceFee back into a rebuilt transaction.
  • Read helpersResult() returns the simulated ScVal for view calls; IsReadCall, NeedsNonInvokerSigningBy, and RequiresEnforcingResimulation classify the simulation outcome.
  • Typed error treeError{Kind} with errors.Is sentinels (ErrInvalidArgs, ErrSourceAccountFailed, ErrSimulationFailed, ErrNotYetSimulated).
  • xdr SCVal buildersScv* one-liner, non-panicking constructors plus ScAddressFromStrkey, validating symbols, 128-bit ranges, and strkey payloads up front.

Why

Ultimately, we're building towards making it easier to send payments using Go. This is the first step.
It is deliberately landed read-only first so the core xdr → client → simulate → decode path can be reviewed against a real consumer and tests before the write path is built on top.

Known limitations

Scoped to the read path; the following are planned for follow-up PRs:

  • No writes. No signer, no sign/send/poll. Result() on a write call returns ErrNotYetSimulated.
  • Untyped args. Arguments are raw []xdr.ScVal; marshaling native Go values into arguments is not here yet.
  • No auto-restore. A RestorePreamble from simulation is surfaced as a KindSimulationFailed error rather than transparently restored + re-simulated.
  • Source accounts must be ed25519 (G…) strkeys; muxed/custom-sequenced sources require building with txnbuild directly.

@marwen-abid marwen-abid self-assigned this Jun 5, 2026
@marwen-abid marwen-abid force-pushed the feat/EN-51-contract-invoke-read branch from 282926c to 81f529e Compare June 5, 2026 15:17
@marwen-abid marwen-abid marked this pull request as ready for review June 5, 2026 17:42
Copilot AI review requested due to automatic review settings June 5, 2026 17:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81f529e125

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread contract/client.go
if network == "" {
return nil, invalidArgsf("New: network passphrase is required")
}
addr, err := xdr.ScAddressFromStrkey(contractID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-contract IDs in New

When New is called with a syntactically valid G... or M... strkey, ScAddressFromStrkey succeeds even though this client is meant to target a contract. That invalid address is then copied into InvokeContractArgs.ContractAddress, so the constructor accepts the bad target and the first Invoke fails later during RPC simulation rather than being rejected up front. Validate that contractID is specifically a C... contract strkey before storing it here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chat is right here, we'd want to only allow C...

@marwen-abid marwen-abid Jun 8, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 9c953d4

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new contract package that provides a higher-level, read-only API for invoking Soroban smart contracts via build → simulate → decode, plus a set of safe, non-panicking xdr ScVal builder helpers for constructing Soroban arguments.

Changes:

  • Added contract.Client and contract.AssembledTransaction to construct and simulate Soroban InvokeHostFunction transactions and expose simulation outputs.
  • Added typed error classification (contract.Error + ErrorKind + sentinels) and auth/result helpers for interpreting simulation outcomes.
  • Added xdr Scv* builder helpers (including ScAddressFromStrkey) with validation and extensive round-trip tests.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
xdr/scval_builders.go Adds validated, non-panicking Scv* helpers and ScAddressFromStrkey for building Soroban argument ScVals.
xdr/scval_builders_test.go Unit tests for address decoding, symbol validation, 128-bit range checks, and deterministic map encoding.
xdr/scval_builders_roundtrip_test.go Ensures Scv* builders round-trip through XDR marshal/unmarshal with stable type discriminants.
contract/result.go Adds AssembledTransaction.Result() for retrieving simulated return values for read calls.
contract/errors.go Introduces contract.Error/ErrorKind classification and errors.Is sentinels.
contract/errors_test.go Tests error formatting, unwrap behavior, and classifier semantics.
contract/client.go Adds contract.Client with functional options and Invoke() to build+simulate calls.
contract/client_test.go Tests client construction, invocation behavior, options, and error classification.
contract/auth.go Adds helpers to classify read calls and interpret simulation auth requirements.
contract/auth_test.go Tests auth helper behavior including enforcing resimulation detection and signer extraction.
contract/assembled_transaction.go Implements build/simulate lifecycle wrapper and simulation decoding/fee handling.
contract/assembled_transaction_test.go Tests simulation folding, error paths, resource fee behavior, and Result() branches.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread contract/client.go
Comment thread contract/assembled_transaction.go
Comment thread contract/assembled_transaction.go
Comment thread contract/client.go
Comment thread contract/auth.go Outdated
Comment thread contract/result.go
Comment thread contract/auth.go Outdated
Comment thread contract/auth.go Outdated
return false
}
for _, entry := range a.AuthEntries {
if entry.Credentials.Type == xdr.SorobanCredentialsTypeSorobanCredentialsAddress &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need to handle SorobanCredentialsTypeSorobanCredentialsAddressV2 after the protocol-next branch merges; it might make sense to retarget that branch so we have P27 support from the get-go

@marwen-abid marwen-abid Jun 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch, thank you! addressed in 3ea3683

Comment thread contract/client.go
if network == "" {
return nil, invalidArgsf("New: network passphrase is required")
}
addr, err := xdr.ScAddressFromStrkey(contractID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chat is right here, we'd want to only allow C...

Comment thread contract/client.go
Comment thread contract/client.go
Comment thread xdr/scval_builders.go
// ScvMap builds an SCV_MAP from a Go map with symbol keys. Entries are
// emitted in lexicographic order so callers get deterministic, canonical
// encoding regardless of map iteration order.
func ScvMap(kv map[string]ScVal) (ScVal, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this but just fyi map keys don't have to be symbols, that's just how structs are expressed in soroban

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's definitely a "simplification". But I figured it hits most use-cases we care about and we can just achieve the required ordering using sort.Strings vs the whole ScVal comparator. If it's too opinionated, happy to switch

Comment thread xdr/scval_builders.go
maxU128 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)) // 2^128 - 1
)

func i128Parts(v *big.Int) (Int128Parts, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exists in the amount package, I'm pretty sure - better to not reinvent the wheel (many soldiers have fallen trying to get this right).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The amount package only goes the other direction from what I can tell: String128 / String128Raw convert Int128Parts -> string. There is no big.Int -> Int128Parts/UInt128Parts constructor in amount.

I'm planning on using this builder later as the amounts will be represented as big.Int. If you think we should represent the amounts in another format, maybe I can take this builder out for now.

I've seen us use string in other areas but I'm not a big fan of that.

// Same five lines for XLM, USDC, or a pure SAC.
tok, _  := asset.New(xdr.MustNewNativeAsset(),
    asset.WithRPC(rpc), asset.WithNetwork(network.TestNetworkPassphrase),
    asset.WithSource(kp.Address()), asset.WithSigner(contract.KeypairSigner(kp)))

at, _   := tok.Transfer(ctx, kp.Address(), recipient, big.NewInt(10_000_000))
sent, _ := at.SignAndSend(ctx, contract.KeypairSigner(kp))
_, _     = sent.Wait(ctx)

Comment on lines +257 to +261
bumped := math.Ceil(float64(minResourceFee) * multiplier)
if bumped > math.MaxInt64 {
return math.MaxInt64
}
return int64(bumped)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could leverage min here (I've found claude and friends to be really bad at using modern Go features):

Suggested change
bumped := math.Ceil(float64(minResourceFee) * multiplier)
if bumped > math.MaxInt64 {
return math.MaxInt64
}
return int64(bumped)
return min(int64(math.Ceil(float64(minResourceFee) * multiplier), math.MaxInt64)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be missing something here but min(int64(x), math.MaxInt64) seems like a no-op since every int64 is ≤ MaxInt64

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b05a137731

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread contract/assembled_transaction.go Outdated

method, args := extractInvocation(params.Op.HostFunction)

tx, err := buildTx(params.SourceAccount, params.Op, params.BaseFee, params.Memo, params.Preconditions)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Default optional preconditions before building

When callers use the exported NewAssembledTransaction directly and leave AssembleParams.Preconditions at its zero value as documented optional, this call passes that zero value into txnbuild.NewTransaction; txnbuild.Preconditions.Validate rejects zero TimeBounds because they were not constructed with NewTimebounds, NewTimeout, or NewInfiniteTimeout. Client.Invoke sets infinite time bounds, so the tests cover only that path, but direct users of this constructor cannot assemble a transaction unless they know to supply preconditions despite the API marking them optional.

Useful? React with 👍 / 👎.

Comment thread contract/auth.go Outdated
// and preserves first-seen order.
//
// It returns nil before Simulate has run.
func (a *AssembledTransaction) NeedsNonInvokerSigningBy() []xdr.ScAddress {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we consider naming this NeedsNonSourceAccountSigningBy or something that doesn't use the term "invoker"? I know the JS SDK (and maybe others?) uses this term so keeping it as-is may also make sense.

@marwen-abid marwen-abid Jun 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good name! addressed in 3ea3683

Comment thread contract/auth.go
// not run, so their footprint and resource fees require signing the entry and
// re-simulating in enforcing mode. Plain Ed25519 account-address signers do not
// need this.
func (a *AssembledTransaction) RequiresEnforcingResimulation() bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an awesome helper. I don't remember the JS SDK including this, and if its still not we should consider adding it, including the code comments on simulate() that reference it.

Adds a new contract package providing a higher-level surface for
invoking Soroban contracts, in a read-only form: it carries a generic
contract call from build -> simulate -> decoded return value. There is
no signer and no submission yet.

What lands:

- contract.Client (New, Invoke): generic invocation against any Soroban
  contract. Invoke builds an InvokeHostFunction op, simulates it, and
  returns an AssembledTransaction ready to read. Configured with
  functional options: WithDefaultSource/WithSource, WithBaseFee,
  WithResourceFeeMultiplier, WithMemo, WithTimeBounds. Read-only calls
  need no funded source — they simulate against a synthetic null account.

- contract.AssembledTransaction (Simulate): models the Soroban
  transaction lifecycle as an explicit, ctx-driven type. Simulate folds
  the simulated SorobanData footprint, auth entries, and verbatim
  minResourceFee back into a rebuilt transaction. A RestorePreamble is
  detected and reported as not-yet-supported rather than silently
  mishandled.

- Read helpers: Result() returns the simulated ScVal for view calls;
  IsReadCall, NeedsNonInvokerSigningBy, and RequiresEnforcingResimulation
  classify the simulation outcome.

- A typed error tree (Error{Kind}, ErrorKind, errors.Is sentinels) for
  the lifecycle stages exercised here (invalid args, source-account,
  simulation, not-yet-simulated).

- xdr SCVal builders (Scv*) and ScAddressFromStrkey: one-liner,
  non-panicking constructors for host-function arguments that validate
  symbols, 128-bit ranges, and strkey payloads up front.

Arguments are raw []xdr.ScVal for now. Native-value argument marshaling,
single-signer writes (signer + sign/send/poll), and auto-restore are not
included and will follow in later PRs.
@marwen-abid marwen-abid force-pushed the feat/EN-51-contract-invoke-read branch from b05a137 to 9463cf1 Compare June 9, 2026 21:48

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9463cf165a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread contract/client.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de31f2920a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread contract/auth.go
- NewAssembledTransaction: default a zero-value Preconditions.TimeBounds to
  NewInfiniteTimeout (matching Invoke) so the documented-optional field builds without a factory-constructed TimeBounds.
- validateSourceAddr: use keypair.ParseAddress, which length-checks the decoded payload, instead of the length-blind strkey.IsValidEd25519PublicKey.
- auth: note that RequiresEnforcingResimulation / NeedsNonSourceAccountSigningBy scope to Address/AddressV2; delegated credentials land with multi-party signing.
- Rename leftover NeedsNonInvokerSigningBy test names/comments.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cecae89bb7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread contract/auth.go
@marwen-abid marwen-abid changed the base branch from main to feat/contract-layer June 17, 2026 20:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e8b33d156

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread contract/client.go
InvokeContract: &xdr.InvokeContractArgs{
ContractAddress: c.contractAddr,
FunctionName: *methodSym.Sym,
Args: args,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Copy invocation args before storing them

When a caller reuses or mutates the args slice after Invoke returns and then calls at.Simulate() again, the simulation request is encoded from the existing Built envelope while the rebuild uses a.op, whose InvokeContract.Args still aliases the caller's mutated slice. That can leave Simulation/ReturnValue describing the old call but Built containing different arguments, so copy the slice before assigning it to the operation.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1. Read-only contract invocation (contract.Client + AssembledTransaction)

4 participants