Skip to content

Commit 21d891a

Browse files
committed
aptos: add shared write-status classification and fee reply
- ClassifyWriteVmStatus for retryable/terminal/already-processed - WriteFailureClassification and known non-Move VM status handling - Tests for write_status normalization and edge cases
1 parent a1e4547 commit 21d891a

2 files changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package aptos
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
type ReceiverExecutionStatus uint8
11+
12+
const (
13+
ReceiverExecutionStatusUnknown ReceiverExecutionStatus = iota
14+
ReceiverExecutionStatusSuccess
15+
ReceiverExecutionStatusReverted
16+
)
17+
18+
type WriteFailureDecision uint8
19+
20+
const (
21+
WriteFailureDecisionRetryable WriteFailureDecision = iota
22+
WriteFailureDecisionTerminal
23+
WriteFailureDecisionAlreadyProcessed
24+
)
25+
26+
type WriteFailureKind uint8
27+
28+
const (
29+
WriteFailureKindUnknown WriteFailureKind = iota
30+
WriteFailureKindForwarderRejected
31+
WriteFailureKindReceiverReverted
32+
WriteFailureKindAlreadyProcessed
33+
)
34+
35+
type WriteFailureClassification struct {
36+
Decision WriteFailureDecision
37+
Kind WriteFailureKind
38+
ReceiverExecutionStatus ReceiverExecutionStatus
39+
Reason string
40+
Message string
41+
}
42+
43+
func (c WriteFailureClassification) Retryable() bool {
44+
return c.Decision == WriteFailureDecisionRetryable
45+
}
46+
47+
func (c WriteFailureClassification) AlreadyProcessed() bool {
48+
return c.Decision == WriteFailureDecisionAlreadyProcessed
49+
}
50+
51+
func (c WriteFailureClassification) Terminal() bool {
52+
return c.Decision != WriteFailureDecisionRetryable
53+
}
54+
55+
func (c WriteFailureClassification) MessagePtr() *string {
56+
if c.Message == "" {
57+
return nil
58+
}
59+
return &c.Message
60+
}
61+
62+
var (
63+
moveAbortLocationRE = regexp.MustCompile(`(?i)move abort(?:ed)?(?: in)? ([^:]+(?:::[^:]+)+):?\s*(.*)$`)
64+
forwarderAbortNames = map[string]string{
65+
"E_INVALID_DATA_LENGTH": "forwarder rejected the report because the report data was malformed",
66+
"E_INVALID_SIGNER": "forwarder rejected the report because a signer was not part of the DON config",
67+
"E_DUPLICATE_SIGNER": "forwarder rejected the report because the signer set contained duplicates",
68+
"E_INVALID_SIGNATURE_COUNT": "forwarder rejected the report because the signature count was invalid",
69+
"E_INVALID_SIGNATURE": "forwarder rejected the report because a signature was invalid",
70+
"E_ALREADY_PROCESSED": "report was already processed by another node",
71+
"E_MALFORMED_SIGNATURE": "forwarder rejected the report because a signature was malformed",
72+
"E_CALLBACK_DATA_NOT_CONSUMED": "forwarder callback data was not consumed by the receiver",
73+
"E_CONFIG_ID_NOT_FOUND": "forwarder rejected the report because the DON config was not found",
74+
"E_INVALID_REPORT_VERSION": "forwarder rejected the report because the report version was invalid",
75+
}
76+
forwarderAbortCodes = map[uint64]string{
77+
1: "forwarder rejected the report because the report data was malformed",
78+
6: "report was already processed by another node",
79+
12: "forwarder callback data was not consumed by the receiver",
80+
15: "forwarder rejected the report because the DON config was not found",
81+
16: "forwarder rejected the report because the report version was invalid",
82+
65538: "forwarder rejected the report because a signer was not part of the DON config",
83+
65539: "forwarder rejected the report because the signer set contained duplicates",
84+
65540: "forwarder rejected the report because the signature count was invalid",
85+
65541: "forwarder rejected the report because a signature was invalid",
86+
65544: "forwarder rejected the report because a signature was malformed",
87+
}
88+
)
89+
90+
func ClassifyWriteVmStatus(vmStatus string) WriteFailureClassification {
91+
vmStatus = normalizeVmStatus(vmStatus)
92+
if vmStatus == "" || strings.EqualFold(vmStatus, "Executed successfully") {
93+
return WriteFailureClassification{
94+
Decision: WriteFailureDecisionRetryable,
95+
Kind: WriteFailureKindUnknown,
96+
Reason: "no vm status available",
97+
}
98+
}
99+
100+
if strings.EqualFold(vmStatus, "Out of gas") {
101+
return WriteFailureClassification{
102+
Decision: WriteFailureDecisionRetryable,
103+
Kind: WriteFailureKindUnknown,
104+
Reason: "transaction ran out of gas",
105+
Message: vmStatus,
106+
}
107+
}
108+
109+
// Explicit handling for known non-Move-abort Aptos VM statuses (retryable).
110+
if reason := knownNonMoveVmStatusReason(vmStatus); reason != "" {
111+
return WriteFailureClassification{
112+
Decision: WriteFailureDecisionRetryable,
113+
Kind: WriteFailureKindUnknown,
114+
Reason: reason,
115+
Message: vmStatus,
116+
}
117+
}
118+
119+
location, details, ok := splitMoveAbort(vmStatus)
120+
if !ok {
121+
return WriteFailureClassification{
122+
Decision: WriteFailureDecisionRetryable,
123+
Kind: WriteFailureKindUnknown,
124+
Reason: "vm failure was not a parsed move abort",
125+
Message: vmStatus,
126+
}
127+
}
128+
129+
if !isForwarderLocation(location) {
130+
return WriteFailureClassification{
131+
Decision: WriteFailureDecisionTerminal,
132+
Kind: WriteFailureKindReceiverReverted,
133+
ReceiverExecutionStatus: ReceiverExecutionStatusReverted,
134+
Reason: "receiver or user module aborted",
135+
Message: fmt.Sprintf("receiver execution failed: %s", vmStatus),
136+
}
137+
}
138+
139+
if name := extractAbortName(details); name != "" {
140+
if name == "E_ALREADY_PROCESSED" {
141+
return WriteFailureClassification{
142+
Decision: WriteFailureDecisionAlreadyProcessed,
143+
Kind: WriteFailureKindAlreadyProcessed,
144+
Reason: "forwarder reported the report was already processed",
145+
Message: fmt.Sprintf("%s: %s", forwarderAbortNames[name], vmStatus),
146+
}
147+
}
148+
if msg, ok := forwarderAbortNames[name]; ok {
149+
return WriteFailureClassification{
150+
Decision: WriteFailureDecisionTerminal,
151+
Kind: WriteFailureKindForwarderRejected,
152+
Reason: "forwarder reported a terminal validation failure",
153+
Message: fmt.Sprintf("%s: %s", msg, vmStatus),
154+
}
155+
}
156+
}
157+
158+
if code, ok := extractAbortCode(details); ok {
159+
if code == 6 {
160+
return WriteFailureClassification{
161+
Decision: WriteFailureDecisionAlreadyProcessed,
162+
Kind: WriteFailureKindAlreadyProcessed,
163+
Reason: "forwarder reported the report was already processed",
164+
Message: fmt.Sprintf("%s: %s", forwarderAbortCodes[code], vmStatus),
165+
}
166+
}
167+
if msg, found := forwarderAbortCodes[code]; found {
168+
return WriteFailureClassification{
169+
Decision: WriteFailureDecisionTerminal,
170+
Kind: WriteFailureKindForwarderRejected,
171+
Reason: "forwarder reported a terminal validation failure",
172+
Message: fmt.Sprintf("%s: %s", msg, vmStatus),
173+
}
174+
}
175+
}
176+
177+
return WriteFailureClassification{
178+
Decision: WriteFailureDecisionRetryable,
179+
Kind: WriteFailureKindUnknown,
180+
Reason: "forwarder abort was not a known terminal code",
181+
Message: vmStatus,
182+
}
183+
}
184+
185+
func normalizeVmStatus(vmStatus string) string {
186+
vmStatus = strings.TrimSpace(vmStatus)
187+
vmStatus = strings.TrimPrefix(vmStatus, "simulated tx unexpected status: ")
188+
vmStatus = strings.TrimPrefix(vmStatus, "simulate bad status: ")
189+
return strings.TrimSpace(vmStatus)
190+
}
191+
192+
// knownNonMoveVmStatusReason returns a reason string for known non-Move-abort Aptos VM
193+
// statuses (e.g. transaction expired, sequence errors). Returns "" if not a known status.
194+
func knownNonMoveVmStatusReason(vmStatus string) string {
195+
lower := strings.ToLower(vmStatus)
196+
switch {
197+
case strings.Contains(lower, "transaction expired"):
198+
return "transaction expired before inclusion"
199+
case strings.Contains(lower, "sequence_number_too_old"), strings.Contains(lower, "sequence_number_too_new"):
200+
return "sequence number conflict; may need nonce resync"
201+
case strings.Contains(lower, "miscellaneous error"):
202+
return "vm miscellaneous error"
203+
case strings.Contains(lower, "insufficient_balance_for_transaction_fee"):
204+
return "insufficient balance for transaction fee"
205+
default:
206+
return ""
207+
}
208+
}
209+
210+
func splitMoveAbort(vmStatus string) (location string, details string, ok bool) {
211+
matches := moveAbortLocationRE.FindStringSubmatch(vmStatus)
212+
if len(matches) == 3 {
213+
return strings.TrimSpace(matches[1]), strings.TrimSpace(matches[2]), true
214+
}
215+
if strings.Contains(strings.ToLower(vmStatus), "move abort") {
216+
return "", strings.TrimSpace(vmStatus), true
217+
}
218+
return "", "", false
219+
}
220+
221+
func isForwarderLocation(location string) bool {
222+
location = strings.ToLower(strings.TrimSpace(location))
223+
return strings.HasSuffix(location, "::forwarder") || strings.Contains(location, "platform::forwarder")
224+
}
225+
226+
func extractAbortName(details string) string {
227+
for _, token := range strings.FieldsFunc(details, func(r rune) bool {
228+
return !(r == '_' || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9'))
229+
}) {
230+
if strings.HasPrefix(token, "E_") {
231+
return token
232+
}
233+
}
234+
return ""
235+
}
236+
237+
func extractAbortCode(details string) (uint64, bool) {
238+
for _, token := range strings.Fields(details) {
239+
token = strings.Trim(token, "(),.;")
240+
if strings.HasPrefix(strings.ToLower(token), "0x") {
241+
if value, err := strconv.ParseUint(token[2:], 16, 64); err == nil {
242+
return value, true
243+
}
244+
}
245+
if value, err := strconv.ParseUint(token, 10, 64); err == nil {
246+
return value, true
247+
}
248+
}
249+
return 0, false
250+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package aptos
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestClassifyWriteVmStatus(t *testing.T) {
9+
t.Run("receiver revert is terminal and marks receiver reverted", func(t *testing.T) {
10+
classification := ClassifyWriteVmStatus("Move abort in 0xabc::receiver::module: 42")
11+
if classification.Decision != WriteFailureDecisionTerminal {
12+
t.Fatalf("expected terminal, got %v", classification.Decision)
13+
}
14+
if classification.Kind != WriteFailureKindReceiverReverted {
15+
t.Fatalf("expected receiver reverted kind, got %v", classification.Kind)
16+
}
17+
if classification.ReceiverExecutionStatus != ReceiverExecutionStatusReverted {
18+
t.Fatalf("expected receiver reverted status, got %v", classification.ReceiverExecutionStatus)
19+
}
20+
})
21+
22+
t.Run("known forwarder abort is terminal", func(t *testing.T) {
23+
classification := ClassifyWriteVmStatus("Move abort in 0x1::platform::forwarder: E_INVALID_SIGNATURE")
24+
if classification.Decision != WriteFailureDecisionTerminal {
25+
t.Fatalf("expected terminal, got %v", classification.Decision)
26+
}
27+
if classification.Kind != WriteFailureKindForwarderRejected {
28+
t.Fatalf("expected forwarder rejected kind, got %v", classification.Kind)
29+
}
30+
})
31+
32+
t.Run("already processed is explicit decision", func(t *testing.T) {
33+
classification := ClassifyWriteVmStatus("Move abort in 0x1::platform::forwarder: E_ALREADY_PROCESSED")
34+
if classification.Decision != WriteFailureDecisionAlreadyProcessed {
35+
t.Fatalf("expected already processed, got %v", classification.Decision)
36+
}
37+
if classification.Kind != WriteFailureKindAlreadyProcessed {
38+
t.Fatalf("expected already processed kind, got %v", classification.Kind)
39+
}
40+
})
41+
42+
t.Run("unknown forwarder abort remains retryable", func(t *testing.T) {
43+
classification := ClassifyWriteVmStatus("Move abort in 0x1::platform::forwarder: 99999")
44+
if classification.Decision != WriteFailureDecisionRetryable {
45+
t.Fatalf("expected retryable, got %v", classification.Decision)
46+
}
47+
if classification.Kind != WriteFailureKindUnknown {
48+
t.Fatalf("expected unknown kind, got %v", classification.Kind)
49+
}
50+
})
51+
52+
t.Run("out of gas remains retryable", func(t *testing.T) {
53+
classification := ClassifyWriteVmStatus("Out of gas")
54+
if classification.Decision != WriteFailureDecisionRetryable {
55+
t.Fatalf("expected retryable, got %v", classification.Decision)
56+
}
57+
if classification.Message == "" {
58+
t.Fatal("expected out of gas message")
59+
}
60+
})
61+
62+
t.Run("normalizeVmStatus strips simulated tx unexpected status prefix", func(t *testing.T) {
63+
// Prefix is stripped so the inner status is classified as forwarder terminal
64+
classification := ClassifyWriteVmStatus("simulated tx unexpected status: Move abort in 0x1::platform::forwarder: E_INVALID_SIGNATURE")
65+
if classification.Decision != WriteFailureDecisionTerminal {
66+
t.Fatalf("expected terminal after prefix strip, got %v", classification.Decision)
67+
}
68+
if classification.Kind != WriteFailureKindForwarderRejected {
69+
t.Fatalf("expected forwarder rejected, got %v", classification.Kind)
70+
}
71+
})
72+
73+
t.Run("normalizeVmStatus strips simulate bad status prefix", func(t *testing.T) {
74+
classification := ClassifyWriteVmStatus("simulate bad status: Out of gas")
75+
if classification.Decision != WriteFailureDecisionRetryable {
76+
t.Fatalf("expected retryable after prefix strip, got %v", classification.Decision)
77+
}
78+
if classification.Reason != "transaction ran out of gas" {
79+
t.Fatalf("expected out of gas reason, got %q", classification.Reason)
80+
}
81+
})
82+
83+
t.Run("forwarder location with ::forwarder suffix is forwarder", func(t *testing.T) {
84+
classification := ClassifyWriteVmStatus("Move abort in 0xaa::forwarder: E_INVALID_SIGNATURE")
85+
if classification.Decision != WriteFailureDecisionTerminal {
86+
t.Fatalf("expected terminal for ::forwarder location, got %v", classification.Decision)
87+
}
88+
if classification.Kind != WriteFailureKindForwarderRejected {
89+
t.Fatalf("expected forwarder rejected, got %v", classification.Kind)
90+
}
91+
})
92+
93+
t.Run("known non-Move status transaction expired is retryable", func(t *testing.T) {
94+
classification := ClassifyWriteVmStatus("Transaction expired")
95+
if classification.Decision != WriteFailureDecisionRetryable {
96+
t.Fatalf("expected retryable, got %v", classification.Decision)
97+
}
98+
if classification.Reason != "transaction expired before inclusion" {
99+
t.Fatalf("expected expired reason, got %q", classification.Reason)
100+
}
101+
})
102+
103+
t.Run("known non-Move status SEQUENCE_NUMBER_TOO_OLD is retryable", func(t *testing.T) {
104+
classification := ClassifyWriteVmStatus("SEQUENCE_NUMBER_TOO_OLD")
105+
if classification.Decision != WriteFailureDecisionRetryable {
106+
t.Fatalf("expected retryable, got %v", classification.Decision)
107+
}
108+
if !strings.Contains(strings.ToLower(classification.Reason), "sequence") {
109+
t.Fatalf("expected sequence in reason, got %q", classification.Reason)
110+
}
111+
})
112+
113+
t.Run("known non-Move status Miscellaneous error is retryable", func(t *testing.T) {
114+
classification := ClassifyWriteVmStatus("Miscellaneous error")
115+
if classification.Decision != WriteFailureDecisionRetryable {
116+
t.Fatalf("expected retryable, got %v", classification.Decision)
117+
}
118+
if classification.Reason != "vm miscellaneous error" {
119+
t.Fatalf("expected miscellaneous reason, got %q", classification.Reason)
120+
}
121+
})
122+
123+
t.Run("known non-Move status insufficient balance is retryable", func(t *testing.T) {
124+
classification := ClassifyWriteVmStatus("INSUFFICIENT_BALANCE_FOR_TRANSACTION_FEE")
125+
if classification.Decision != WriteFailureDecisionRetryable {
126+
t.Fatalf("expected retryable, got %v", classification.Decision)
127+
}
128+
if classification.Reason != "insufficient balance for transaction fee" {
129+
t.Fatalf("expected balance reason, got %q", classification.Reason)
130+
}
131+
})
132+
}

0 commit comments

Comments
 (0)