Skip to content

Commit 8bebe15

Browse files
authored
refactor(receipt): disentangle Receipt from Invocation (#12)
## Summary `Receipt` becomes its own Go type, distinct from `Invocation` — **without changing the wire format**. On the wire a receipt is still a `/ucan/assert/receipt` invocation; that encoding tracks the UCAN WG draft ([ucan-wg/receipt#1](ucan-wg/receipt#1)) and is unchanged here. The change is entirely in the Go API surface. ## Motivation A receipt has always been encoded as a `/ucan/assert/receipt` invocation, and the Go `Receipt` type embedded `invocation.Invocation` to reuse the envelope / sigPayload / varsig plumbing. That reuse was a sensible starting point. As more code was built on the API, a few rough edges emerged — all on the Go side, none on the wire — that this PR smooths out: 1. **Clearer type boundaries.** Because `*Receipt` embedded `invocation.Invocation`, it satisfied `ucan.Invocation` — so anything declared `func(inv ucan.Invocation)` would quietly accept a receipt. That has worked in practice, but it makes call-site review harder and pushes helpers like `validator.VerifyInvocationSignature` toward being defensively written. With `Receipt` as its own type, signatures say what they mean. 2. **Tighter discoverability.** Autocomplete on a receipt previously surfaced the full invocation method set — `Subject`, `Audience`, `Command`, `Proofs`, `Cause`, `Task`, … — most of which don't carry receipt-specific information (`Command()` is always the constant, `Subject()`/`Audience()` are always the executor). The type now exposes only the receipt-meaningful accessors: `Issuer`, `Ran`, `Out`, `IssuedAt`, `Nonce`, `MetadataBytes`, `SignedBytes`, `Signature`, plus `Link`/`Bytes`. 3. **A receipt-specific options surface.** `receipt.Option` was a re-export of `invocation.Option`, so options like `WithProofs` / `WithCause` appeared on the receipt API without a defined meaning for a receipt today. `receipt.Option` is now its own type that translates to invocation options internally, exposing just `WithNonce` / `WithNoNonce` / `WithIssuedAt` / `WithMetadata`. ## What changed - `Receipt` holds an `invocation.Invocation` as an **unexported field** instead of embedding it. `*Receipt` no longer satisfies `ucan.Invocation`. - `ucan.Receipt` is a standalone interface — it no longer embeds `ucan.Invocation`. - `receipt.Option` is its own type with a receipt-only option set. - Adds `receipt.VerifySignature`, mirroring the `invocation` / `delegation` helpers. - Construction (`issue` → `invocation.Invoke`) and decoding (via `invocation.Invocation`) are unchanged, so encoded receipts are byte-identical to `main`. ## Intentionally deferred `prf` (proofs) and `exp` (expiration) are load-bearing receipt fields in the WG draft — proofs for delegated receipt issuance, expiration for assertion TTL. They are physically present on the wire today (a receipt is an invocation), but their receipt-level semantics are still under discussion upstream. Rather than ship half-defined accessors, the API stays minimal now and will grow `Proofs` / `Expiration` accessors and options once the spec settles. ## Wire compatibility **Unchanged.** Encoded receipts are byte-identical to `main`. Two tests pin the boundary in both directions: - `TestWireFormatIsInvocation` — a receipt's exact bytes decode via `invocation.Decode`, re-encode verbatim, verify through `invocation.VerifySignature`, and carry the executor as `iss`/`sub`/`aud`. - `TestNotAReceipt` (invocation package) — a non-receipt invocation is rejected by `receipt.Decode`. ## Test plan - [x] `go build ./...` and `go vet ./...` - [x] `go test ./...` — full suite green - [x] `TestWireFormatIsInvocation` — receipt bytes are a valid, verifiable invocation - [x] `TestNotAReceipt` — non-receipt invocations don't decode as receipts - [x] `TestNotInvocation` — `*Receipt` does not satisfy `ucan.Invocation` - [x] `TestVerifySignature`, `TestOptions`, `TestIssueOK` / `TestIssueErr`
1 parent 101376a commit 8bebe15

5 files changed

Lines changed: 370 additions & 52 deletions

File tree

ucan/invocation/invocation_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/fil-forge/ucantone/ucan"
1212
"github.com/fil-forge/ucantone/ucan/command"
1313
"github.com/fil-forge/ucantone/ucan/invocation"
14+
"github.com/fil-forge/ucantone/ucan/receipt"
1415
"github.com/stretchr/testify/require"
1516
cbg "github.com/whyrusleeping/cbor-gen"
1617
)
@@ -257,3 +258,30 @@ func TestInvokeRejectsNonMapArgs(t *testing.T) {
257258
require.Error(t, err)
258259
require.Contains(t, err.Error(), "args must encode as a CBOR map")
259260
}
261+
262+
// TestNotAReceipt is the mirror of receipt.TestWireFormatIsInvocation. A
263+
// receipt is wire-identical to a /ucan/assert/receipt invocation, but the
264+
// discrimination must hold in the other direction too: a plain invocation
265+
// with any other command MUST NOT decode as a receipt. This pins the
266+
// boundary so receipt.Decode can't silently accept arbitrary invocations.
267+
func TestNotAReceipt(t *testing.T) {
268+
issuer := testutil.RandomSigner(t)
269+
subject := testutil.RandomDID(t)
270+
command := testutil.Must(command.Parse("/test/invoke"))(t)
271+
272+
inv, err := invocation.Invoke(issuer, subject, command, datamodel.Map{})
273+
require.NoError(t, err)
274+
275+
encoded, err := invocation.Encode(inv)
276+
require.NoError(t, err)
277+
278+
// The invocation decodes fine as an invocation...
279+
_, err = invocation.Decode(encoded)
280+
require.NoError(t, err)
281+
282+
// ...but must be rejected as a receipt: its command isn't the receipt
283+
// command, so there are no Ran/Out fields to recover.
284+
_, err = receipt.Decode(encoded)
285+
require.Error(t, err, "a non-receipt invocation must not decode as a receipt")
286+
require.Contains(t, err.Error(), "invalid receipt command")
287+
}

ucan/receipt/options.go

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,49 @@
11
package receipt
22

3-
import "github.com/fil-forge/ucantone/ucan/invocation"
4-
5-
type Option = invocation.Option
6-
7-
var (
8-
WithExpiration = invocation.WithExpiration
9-
WithNoExpiration = invocation.WithNoExpiration
10-
WithNonce = invocation.WithNonce
11-
WithNoNonce = invocation.WithNoNonce
12-
WithMetadata = invocation.WithMetadata
13-
WithProofs = invocation.WithProofs
14-
WithCause = invocation.WithCause
3+
import (
4+
"github.com/fil-forge/ucantone/ipld"
5+
"github.com/fil-forge/ucantone/ucan"
6+
"github.com/fil-forge/ucantone/ucan/invocation"
157
)
8+
9+
// Option configures a UCAN receipt.
10+
//
11+
// A receipt is encoded on the wire as a /ucan/assert/receipt invocation (see
12+
// ucan-wg/receipt#1), so each receipt Option translates to an invocation
13+
// Option internally. Only the options that are meaningful for a receipt are
14+
// exposed here; proof/expiration options will be added once their semantics
15+
// are settled in the spec.
16+
type Option func(cfg *receiptConfig)
17+
18+
type receiptConfig struct {
19+
invOpts []invocation.Option
20+
}
21+
22+
// WithNonce configures the nonce value for the receipt.
23+
func WithNonce(nnc []byte) Option {
24+
return func(cfg *receiptConfig) {
25+
cfg.invOpts = append(cfg.invOpts, invocation.WithNonce(nnc))
26+
}
27+
}
28+
29+
// WithNoNonce configures an empty nonce value for the receipt.
30+
func WithNoNonce() Option {
31+
return func(cfg *receiptConfig) {
32+
cfg.invOpts = append(cfg.invOpts, invocation.WithNoNonce())
33+
}
34+
}
35+
36+
// WithIssuedAt sets the time at which the receipt was issued, in seconds
37+
// since the Unix epoch.
38+
func WithIssuedAt(iat ucan.UnixTimestamp) Option {
39+
return func(cfg *receiptConfig) {
40+
cfg.invOpts = append(cfg.invOpts, invocation.WithIssuedAt(iat))
41+
}
42+
}
43+
44+
// WithMetadata configures arbitrary metadata for the receipt.
45+
func WithMetadata(meta ipld.Map) Option {
46+
return func(cfg *receiptConfig) {
47+
cfg.invOpts = append(cfg.invOpts, invocation.WithMetadata(meta))
48+
}
49+
}

ucan/receipt/receipt.go

Lines changed: 103 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,50 @@ import (
66
"fmt"
77
"io"
88

9+
cid "github.com/ipfs/go-cid"
10+
cbg "github.com/whyrusleeping/cbor-gen"
11+
12+
"github.com/fil-forge/ucantone/did"
913
"github.com/fil-forge/ucantone/ipld/datamodel"
1014
"github.com/fil-forge/ucantone/result"
1115
rsdm "github.com/fil-forge/ucantone/result/datamodel"
1216
"github.com/fil-forge/ucantone/ucan"
1317
"github.com/fil-forge/ucantone/ucan/command"
1418
"github.com/fil-forge/ucantone/ucan/invocation"
1519
rdm "github.com/fil-forge/ucantone/ucan/receipt/datamodel"
16-
cid "github.com/ipfs/go-cid"
17-
cbg "github.com/whyrusleeping/cbor-gen"
1820
)
1921

2022
const Command = command.Command("/ucan/assert/receipt")
2123

2224
// Receipt is a signed attestation that a task was executed and produced a
23-
// particular result. The result is held as raw CBOR bytes — typed callers
24-
// decode via [Receipt.Out] and UnmarshalCBOR into the schema expected for
25-
// the receipt's task.
25+
// particular result.
26+
//
27+
// On the wire a receipt is a /ucan/assert/receipt invocation (per the UCAN WG
28+
// draft, ucan-wg/receipt#1): the executor is issuer/subject/audience and the
29+
// Ran/Out fields travel in the invocation's args. That wire shape is
30+
// deliberate and is not changed here.
31+
//
32+
// At the Go level, Receipt wraps the underlying invocation as an unexported
33+
// field rather than embedding it, so a *Receipt does not satisfy
34+
// ucan.Invocation and only exposes accessors that are meaningful for a
35+
// receipt. The result is held as raw CBOR bytes — typed callers decode via
36+
// [Receipt.Out] into the schema expected for the receipt's task.
2637
type Receipt struct {
27-
invocation.Invocation
38+
inv invocation.Invocation
2839
ran cid.Cid
2940
out result.Result[[]byte, []byte]
3041
}
3142

43+
// Issuer is the DID of the executor that signed this attestation.
44+
func (rcpt *Receipt) Issuer() did.DID {
45+
return rcpt.inv.Issuer()
46+
}
47+
48+
// Ran is the CID of the executed task this receipt is for.
49+
func (rcpt *Receipt) Ran() cid.Cid {
50+
return rcpt.ran
51+
}
52+
3253
// Out is the attested result of the execution of the task. The Result's
3354
// Ok and Err branches hold raw CBOR bytes; decode into the typed cborgen
3455
// struct that matches the executed task's expected output:
@@ -46,23 +67,72 @@ func (rcpt *Receipt) Out() result.Result[[]byte, []byte] {
4667
return rcpt.out
4768
}
4869

49-
// Ran is the CID of the executed task this receipt is for.
50-
func (rcpt *Receipt) Ran() cid.Cid {
51-
return rcpt.ran
70+
// IssuedAt is the timestamp at which the executor signed this receipt, or nil
71+
// if unset.
72+
func (rcpt *Receipt) IssuedAt() *ucan.UnixTimestamp {
73+
return rcpt.inv.IssuedAt()
74+
}
75+
76+
// Nonce returns the receipt's nonce.
77+
func (rcpt *Receipt) Nonce() []byte {
78+
return rcpt.inv.Nonce()
79+
}
80+
81+
// MetadataBytes returns the raw CBOR bytes of the meta field, or nil if
82+
// metadata is not set.
83+
func (rcpt *Receipt) MetadataBytes() []byte {
84+
return rcpt.inv.MetadataBytes()
85+
}
86+
87+
// SignedBytes returns the raw CBOR bytes of the SigPayload — the bytes the
88+
// issuer signed over. Verification operates on these directly.
89+
func (rcpt *Receipt) SignedBytes() []byte {
90+
return rcpt.inv.SignedBytes()
91+
}
92+
93+
// Signature returns the executor's signature over the SignedBytes.
94+
func (rcpt *Receipt) Signature() ucan.Signature {
95+
return rcpt.inv.Signature()
96+
}
97+
98+
// Link returns the IPLD link that corresponds to the encoded bytes.
99+
func (rcpt *Receipt) Link() cid.Cid {
100+
return rcpt.inv.Link()
101+
}
102+
103+
// Bytes returns the dag-cbor encoded bytes of this receipt.
104+
func (rcpt *Receipt) Bytes() []byte {
105+
return rcpt.inv.Bytes()
52106
}
53107

54108
func (rcpt *Receipt) MarshalCBOR(w io.Writer) error {
55-
_, err := w.Write(rcpt.Bytes())
56-
return err
109+
return rcpt.inv.MarshalCBOR(w)
57110
}
58111

59112
func (rcpt *Receipt) UnmarshalCBOR(r io.Reader) error {
60-
*rcpt = Receipt{}
61-
62113
inv := invocation.Invocation{}
63114
if err := inv.UnmarshalCBOR(r); err != nil {
64115
return err
65116
}
117+
return rcpt.fromInvocation(inv)
118+
}
119+
120+
func (rcpt *Receipt) MarshalDagJSON(w io.Writer) error {
121+
return rcpt.inv.MarshalDagJSON(w)
122+
}
123+
124+
func (rcpt *Receipt) UnmarshalDagJSON(r io.Reader) error {
125+
inv := invocation.Invocation{}
126+
if err := inv.UnmarshalDagJSON(r); err != nil {
127+
return err
128+
}
129+
return rcpt.fromInvocation(inv)
130+
}
131+
132+
// fromInvocation validates that inv is a well-formed receipt invocation and
133+
// populates rcpt from it.
134+
func (rcpt *Receipt) fromInvocation(inv invocation.Invocation) error {
135+
*rcpt = Receipt{}
66136

67137
if inv.Command() != Command {
68138
return fmt.Errorf("invalid receipt command %s, expected %s", inv.Command().String(), Command.String())
@@ -83,7 +153,7 @@ func (rcpt *Receipt) UnmarshalCBOR(r io.Reader) error {
83153
return errors.New("invalid result, neither ok nor error")
84154
}
85155

86-
rcpt.Invocation = inv
156+
rcpt.inv = inv
87157
rcpt.ran = receiptArgs.Ran
88158
rcpt.out = out
89159
return nil
@@ -121,6 +191,11 @@ func issue(executor ucan.Signer, ran cid.Cid, ok, errVal cbg.CBORMarshaler, opti
121191
return nil, errors.New("issue requires exactly one of ok or err to be non-nil")
122192
}
123193

194+
cfg := receiptConfig{}
195+
for _, opt := range options {
196+
opt(&cfg)
197+
}
198+
124199
var outModel rsdm.ResultModel
125200
var outBytes []byte
126201
if ok != nil {
@@ -139,12 +214,12 @@ func issue(executor ucan.Signer, ran cid.Cid, ok, errVal cbg.CBORMarshaler, opti
139214
outBytes = raw.Bytes()
140215
}
141216

142-
options = append(options, invocation.WithAudience(executor.DID()))
217+
invOpts := append(cfg.invOpts, invocation.WithAudience(executor.DID()))
143218

144219
inv, err := invocation.Invoke(executor, executor.DID(), Command, &rdm.ArgsModel{
145220
Ran: ran,
146221
Out: outModel,
147-
}, options...)
222+
}, invOpts...)
148223
if err != nil {
149224
return nil, err
150225
}
@@ -157,12 +232,21 @@ func issue(executor ucan.Signer, ran cid.Cid, ok, errVal cbg.CBORMarshaler, opti
157232
}
158233

159234
return &Receipt{
160-
Invocation: *inv,
161-
ran: ran,
162-
out: out,
235+
inv: *inv,
236+
ran: ran,
237+
out: out,
163238
}, nil
164239
}
165240

241+
// VerifySignature verifies the receipt's signature against the literal
242+
// signed-payload bytes preserved on decode.
243+
func VerifySignature(rcpt ucan.Receipt, verifier ucan.Verifier) (bool, error) {
244+
if rcpt.Issuer() != verifier.DID() {
245+
return false, nil
246+
}
247+
return verifier.Verify(rcpt.SignedBytes(), rcpt.Signature().Bytes()), nil
248+
}
249+
166250
func marshalToRaw(m cbg.CBORMarshaler) (datamodel.Raw, error) {
167251
var buf bytes.Buffer
168252
if err := m.MarshalCBOR(&buf); err != nil {

0 commit comments

Comments
 (0)