-
Notifications
You must be signed in to change notification settings - Fork 43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add opt in reentrancy to soroban #1491
base: main
Are you sure you want to change the base?
Changes from all commits
43a3ed8
efd3537
22f5770
d3bc92b
450cbf3
abe39c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
[toolchain] | ||
channel = "stable" | ||
channel = "1.81" | ||
targets = ["wasm32-unknown-unknown"] | ||
components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1560,6 +1560,61 @@ | |
], | ||
"return": "Val", | ||
"docs": "Calls a function in another contract with arguments contained in vector `args`, returning either the result of the called function or an `Error` if the called function failed. The returned error is either a custom `ContractError` that the called contract returns explicitly, or an error with type `Context` and code `InvalidAction` in case of any other error in the called contract (such as a host function failure that caused a trap). `try_call` might trap in a few scenarios where the error can't be meaningfully recovered from, such as running out of budget." | ||
}, | ||
{ | ||
"export": "1", | ||
"name": "call_reentrant", | ||
"args": [ | ||
{ | ||
"name": "contract", | ||
"type": "AddressObject" | ||
}, | ||
{ | ||
"name": "func", | ||
"type": "Symbol" | ||
}, | ||
{ | ||
"name": "args", | ||
"type": "VecObject" | ||
} | ||
], | ||
"return": "Val", | ||
"docs": "Calls a function in another contract with arguments contained in vector `args`. If the call is successful, returns the result of the called function. Traps otherwise. This functions enables re-entrancy in the immediate cross-contract call.", | ||
"min_supported_protocol": 21 | ||
}, | ||
{ | ||
"export": "2", | ||
"name": "try_call_reentrant", | ||
"args": [ | ||
{ | ||
"name": "contract", | ||
"type": "AddressObject" | ||
}, | ||
{ | ||
"name": "func", | ||
"type": "Symbol" | ||
}, | ||
{ | ||
"name": "args", | ||
"type": "VecObject" | ||
} | ||
], | ||
"return": "Val", | ||
"docs": "Calls a function in another contract with arguments contained in vector `args`, returning either the result of the called function or an `Error` if the called function failed. The returned error is either a custom `ContractError` that the called contract returns explicitly, or an error with type `Context` and code `InvalidAction` in case of any other error in the called contract (such as a host function failure that caused a trap). `try_call` might trap in a few scenarios where the error can't be meaningfully recovered from, such as running out of budget. This functions enables re-entrancy in the immediate cross-contract call.", | ||
"min_supported_protocol": 21 | ||
}, | ||
{ | ||
"export": "3", | ||
"name": "set_reentrant", | ||
"args": [ | ||
{ | ||
"name": "enabled", | ||
"type": "Bool" | ||
} | ||
], | ||
"return": "Void", | ||
"docs": "Enables the current contract to specify the reentrancy rules.", | ||
"min_supported_protocol": 21 | ||
Comment on lines
+1607
to
+1617
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the idea with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Set reentrant just instructs the host that the contract in this context (or better, currently it sets the flag on the whole host). The current implementation requires two specialized host functions to have the callparam option with the reentry enabled (while call and try_call always have it disabled), and |
||
} | ||
] | ||
}, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In an outgoing call graph that looks like this:
Does it mean 1️⃣ that reentry into
A
is only allowed fromB
? For example:Or 2️⃣ that reentry into
A
is allowed from any contract that is executing further down the stack thanA
's call out? For example:I think 2️⃣ fits better with narrative on what it means to call out to another contract. What that other contract does, whether it is a monolith or a micro component is irrelevant to the calling contract. The calling contract merely needs to acknowledge that it is capable to handle being re-entered during the call. By who, is mostly irrelevant.
Are there cases where the who is important?
If I'm completely misunderstanding, please correct me 😄.
cc @dmkozh @sisuresh
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this was also partially discussed on the thread. Currently the implementation is fairly unsafe because when reentrancy is enabled the called contract can be reentrant on any contract down the stack. This means that scenario 2 (which is what I think we should go for) is possible, but it also makes possible a scenario where e.g D is reentrant on B which didn't have reentrancy enabled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just wanted to confirm that I think we should go with 2 (reentry is enabled into A only, but from any downstream contract in the reentry scope). One extension we've discussed in the thread is to also limit the depth of reentry explicitly (i.e. don't specify who can reenter, but have an ability to limit the reentry e.g. to the direct calls), but I'm not sure yet if that's necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 I think depth is unnecessary and somewhat breaks the abstraction and possibly interop. For eg someone puts an arbitrary depth and then some pluggavle contract invocations work while others don't.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW I think for depth the only useful options are 1 (self-reentry), 2 (call a contract that re-enters into me) and 'infinity' (for arbitrary reentrance). Self-reentry is something we do for auth, so it's definitely not useless, but I'm not sure if there are use cases for it in the regular contracts (likely it can be better achieved just by factoring out the function logic into regular functions). Depth 2 may be useful for some well-defined protocols that want to protect themselves from a 'man-in-the-middle' scenarios (e.g. imagine contract A calls contract B and it trusts contract B to reenter contract A, but doesn't trust any contracts C,D,... that B may call). My intuition is that A->B->A reentrance may be useful quite often and tight coupling between A and B is actually intentional for that. Anything above 2 is probably too fine-grained and should just fall into 'infinity' bucket. So if I were to implement depth, I'd probably go with these 3 options. That said, I'm also ok with leaving the depth out completely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not strong opposed to a depth parameter, but I don't yet grasp where it would move the needle on safety. It looks like it could be used to limit exposure, but on its own doesn't actually make contracts reentrant safe, and may harm interop and contract composability.
I'm saying this mostly because when it comes to reentry,
A
should not trust any dependency to reenter in an unsafe way.A
should be internally defensively organised so that its state, irrespective of when or how it is reentered, cannot be moved into a state that is invalid.The only exception I can think of is when
A
andB
are components of a single system, sharing the same developer. But the presence of an intentional vulnerability inA
thatB
promises not to exploit still doesn't feel right.Use cases and concrete examples are needed to work out if depth is needed, or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we could work through some case studies to determine usefulness.